Self-host a Mastodon instance on a VPS
A working single-user or small-server Mastodon install with Docker, Postgres, Redis, and the federation gotchas that nobody warns you about.

Mastodon is harder to self-host than people pretend, but easier than a full mail server. The first time it took us a weekend; the second time, a couple of hours. This guide is the second-time playbook.
Decide single-user vs open instance
- Single-user ("just me, federating with the world"): tiny VPS, minimal moderation work, the case this guide targets.
- Small open instance (50-500 accounts): same software but moderation, content policy, GDPR registration, abuse reports. A real ongoing commitment.
If you're not sure, start single-user. You can open it up later.
Sizing for single-user
- 4 vCPU, 8 GB RAM, 80 GB disk to start.
- Object storage off the VPS for media (Backblaze B2 or our preferred: minio on a second box). Federated media accumulates fast.
- Plan for 50+ GB of media within a year on an active single-user instance.
Stack
- Ubuntu 24.04
- Docker + Compose (easiest path)
- Postgres (version pinned by the official compose file, currently 14)
- Redis 7
- Caddy reverse proxy out front
If you prefer non-Docker, Mastodon's official guide covers the bare-metal install. Docker is faster to set up and easier to upgrade.
DNS and TLS first
One decision to make now: your handle. This guide uses the split-domain setup, so your handle is @you@example.com while the web app lives at mastodon.example.com. For that to work, the apex domain example.com has to answer a few well-known discovery paths (/.well-known/webfinger, /.well-known/host-meta, /.well-known/nodeinfo) by redirecting them to the Mastodon host. Skip that and everything looks fine locally, but nobody on other servers can find your account: federation, the whole point, silently breaks. If you'd rather not deal with the apex, or example.com already runs a site you can't touch, set both LOCAL_DOMAIN and WEB_DOMAIN to mastodon.example.com later on and your handle becomes @you@mastodon.example.com; then you can drop the example.com block below.
Point mastodon.example.com, and for split-domain also example.com, at your VPS via A and AAAA records. Set up Caddy with a placeholder block:
mastodon.example.com {
handle /api/v1/streaming* {
reverse_proxy localhost:4000
}
handle {
reverse_proxy localhost:3000
}
}
example.com {
redir /.well-known/webfinger https://mastodon.example.com{uri} permanent
redir /.well-known/host-meta https://mastodon.example.com{uri} permanent
redir /.well-known/nodeinfo https://mastodon.example.com{uri} permanent
}
The streaming route is not optional. The official compose file runs Mastodon as two HTTP services behind the proxy: the web app on port 3000 and the streaming API on port 4000. Skip that handle block and the site looks fine, but live timelines and notifications silently stop updating.
If the apex is hosted somewhere else (an existing website, a static host), add the equivalent redirects there instead; where they live doesn't matter, only that they answer.
Reload Caddy. Verify the certs provision for both hosts before continuing. (See the Caddy guide.)
docker-compose
First, Docker itself: install Docker Engine and the Compose plugin from the official Docker apt repository (docs.docker.com/engine/install/ubuntu has the current commands). docker compose version should answer before you go on; nothing below works without it.
Create /srv/mastodon, drop the official docker-compose.yml from mastodon/mastodon on GitHub into it, and work from that directory. Make sure the image: lines are pinned to the release tag you're installing rather than latest; you'll bump these same tags when you upgrade later. Then create /srv/mastodon/.env.production (the compose file loads it via env_file) with:
LOCAL_DOMAIN=example.com
WEB_DOMAIN=mastodon.example.com
SINGLE_USER_MODE=false
SECRET_KEY_BASE=<generate>
OTP_SECRET=<generate>
VAPID_PRIVATE_KEY=<generate>
VAPID_PUBLIC_KEY=<generate>
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=<generate>
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=<generate>
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=<generate>
DB_HOST=db
DB_USER=postgres
DB_NAME=mastodon
REDIS_HOST=redis
SMTP_SERVER=...
SMTP_PORT=587
SMTP_LOGIN=...
SMTP_PASSWORD=...
SMTP_FROM_ADDRESS=mastodon@example.com
S3_ENABLED=true
S3_BUCKET=...
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
S3_PROTOCOL=https
S3_HOSTNAME=...
S3_ENDPOINT=...
LOCAL_DOMAIN and WEB_DOMAIN here match the split-domain choice from the DNS section. If you went single-domain, set both to mastodon.example.com. Either way, pick once: LOCAL_DOMAIN is baked into every account and follower relationship, and changing it later effectively means starting a new instance.
SINGLE_USER_MODE starts as false deliberately: with it on, Mastodon disables registrations, and you couldn't sign yourself up in the first-boot step below. You'll flip it to true once your account exists.
The S3_* block needs an S3-compatible bucket that already exists: any provider works (Backblaze B2, Cloudflare R2, AWS S3, or your own minio), so create a bucket and an access key pair there and fill in the hostname and endpoint it gives you. For a strictly single-user instance you can start with S3_ENABLED=false and local disk, but plan the move: federated media fills a small VPS within months.
Generate the secrets. First SECRET_KEY_BASE and OTP_SECRET, one run each:
docker compose run --rm web bundle exec rake secret
Then the web push keypair. This prints VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY lines you can paste straight into .env.production:
docker compose run --rm web bundle exec rake mastodon:webpush:generate_vapid_key
Then the database encryption keys. Mastodon 4.3 and later refuses to boot without these:
docker compose run --rm web bundle exec rails db:encryption:init
Copy the three ACTIVE_RECORD_ENCRYPTION_* lines it prints into .env.production. Treat them like the database itself: they encrypt columns at rest, and if you lose them that data is unrecoverable, so .env.production belongs in your backups.
Database setup
docker compose run --rm web rails db:setup
Bring it up
docker compose up -d
docker compose logs -f web sidekiq streaming
Visit https://mastodon.example.com. Sign up the single account that will be you, confirm via the email Mastodon sends through your SMTP provider.
Make yourself an admin
docker compose run --rm web tootctl accounts modify yourusername --role Owner
Now that your account exists, lock the instance to it: set SINGLE_USER_MODE=true in .env.production and run docker compose up -d again to restart with the new setting. From here on the front page redirects to your profile and open registration stays off.
Federation gotchas
- Outbound mail must work. Use a transactional provider (Mailgun, Postmark, SES). Don't try to run Postfix here.
- Object storage is mandatory for any non-tiny instance. The local-disk option fills any sane VPS within months.
- Backups: Postgres + Redis (RDB) + your
.env.production. Media is in object storage and is regenerable from federation in theory but painful in practice. - Followers cache: Mastodon expects the WEB_DOMAIN to never change. Pick once. Migrating an instance is technically possible but practically miserable.
Monitoring
tootctl and Sidekiq's web UI (route to it via Caddy with basic auth) are the two tools you'll use. Watch the queue depth on Sidekiq; if default or push stays above 1000 for hours, your instance is undersized.
Operational notes
- Upgrades: there's no git checkout here, just a compose file. Read the release notes first (major upgrades sometimes need extra steps) and take a Postgres dump before migrating, then bump the pinned
image:tags indocker-compose.ymlto the new release and rundocker compose pull,docker compose run --rm web rails db:migrate,docker compose up -d. - Tooting from CLI:
tootctl statuses post. Useful for cron-driven posts. - Pruning:
tootctl media removeandtootctl statuses removekeep storage in check on instances that federate broadly. Both delete permanently, the first cached remote media from storage, the second old remote statuses from the database, and neither has an undo. Run each with--dry-runfirst, and take a Postgres dump before the real run.
That's a working Mastodon. The hard parts are the operational ones, not the install.
Continue reading