Self-host a Mastodon server
Follow this on your own server. Start free, no card needed
Running your own corner of the fediverse means running five things that all have to agree with each other: a Rails web process, a separate streaming server for the real-time timeline, a Sidekiq worker for federation and notifications, Postgres, and Redis. Get the domain wrong in any one of them and you get an instance that half-loads or silently stops federating. By the end of this guide you’ll have a real single-server Mastodon instance running all five, behind HTTPS, with backups and an honest look at what it actually costs to run well.
What you’ll need
- A server with at least 4 vCPUs and 8 GB of RAM for a small instance; this guide uses the five services on one box, which is more than the web process alone needs. A Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB NVMe, €8.99/month excl. VAT) is the realistic starting point once Postgres, Redis, the streaming server and Sidekiq are all competing for resources on the same machine; growing past a few hundred active users is when splitting Postgres onto its own box starts to matter.
- A domain name you can point an A record at. Mastodon ties your account handles to this domain permanently; there’s no supported way to move an existing instance to a new domain later without starting over.
- An SMTP provider. This is not optional: Mastodon requires working email delivery for account confirmation, password resets and moderation notices, and ships with none configured by default. Any transactional email provider works.
- SSH access to the server.
Step 1: Point DNS and open the firewall
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable
Create an A record for your domain pointing at the server’s public IP. Give it time to propagate before Step 6.
Step 2: Generate every secret up front
Mastodon needs six distinct secret values before it will boot: a Rails secret key base, VAPID keys for push notifications, and three Active Record encryption keys. Each has its own generator command, run through the image itself once it’s pulled:
mkdir -p /opt/mastodon && cd /opt/mastodon
docker pull ghcr.io/mastodon/mastodon:v4.7.3
docker run --rm ghcr.io/mastodon/mastodon:v4.7.3 bundle exec rails secret
docker run --rm ghcr.io/mastodon/mastodon:v4.7.3 bundle exec rake mastodon:webpush:generate_vapid_key
docker run --rm ghcr.io/mastodon/mastodon:v4.7.3 bundle exec rake db:encryption:init
The first command prints SECRET_KEY_BASE. The second prints a VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY pair. The third prints all three ACTIVE_RECORD_ENCRYPTION_* values at once. Save every one of them:
cat > .env <<'EOF'
POSTGRES_PASSWORD=generate-with-openssl-rand-hex-24
REDIS_PASSWORD=generate-with-openssl-rand-hex-24
SECRET_KEY_BASE=paste-from-rails-secret
VAPID_PRIVATE_KEY=paste-from-generate-vapid-key
VAPID_PUBLIC_KEY=paste-from-generate-vapid-key
AR_ENCRYPTION_PRIMARY_KEY=paste-from-db-encryption-init
AR_ENCRYPTION_DETERMINISTIC_KEY=paste-from-db-encryption-init
AR_ENCRYPTION_KEY_DERIVATION_SALT=paste-from-db-encryption-init
SMTP_SERVER=smtp.your-provider.com
SMTP_LOGIN=your-smtp-username
SMTP_PASSWORD=your-smtp-password
EOF
chmod 600 .env
Step 3: Write the Compose file
services:
mastodon:
image: ghcr.io/mastodon/mastodon:v4.7.3
restart: unless-stopped
command: bash -c "bundle exec rails db:prepare && bundle exec puma -C config/puma.rb"
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
environment:
LOCAL_DOMAIN: social.yourdomain.com
DB_HOST: db
DB_PORT: "5432"
DB_NAME: mastodon
DB_USER: mastodon
DB_PASS: ${POSTGRES_PASSWORD}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD}
STREAMING_API_BASE_URL: wss://social.yourdomain.com
SMTP_FROM_ADDRESS: notifications@yourdomain.com
volumes:
- mastodon_system:/mastodon/public/system
expose:
- "3000"
streaming:
image: ghcr.io/mastodon/mastodon-streaming:v4.7.3
restart: unless-stopped
command: node ./streaming/index.js
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
environment:
PORT: "4000"
LOCAL_DOMAIN: social.yourdomain.com
DB_HOST: db
DB_PORT: "5432"
DB_NAME: mastodon
DB_USER: mastodon
DB_PASS: ${POSTGRES_PASSWORD}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD}
expose:
- "4000"
sidekiq:
image: ghcr.io/mastodon/mastodon:v4.7.3
restart: unless-stopped
command: bundle exec sidekiq
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
environment:
LOCAL_DOMAIN: social.yourdomain.com
DB_HOST: db
DB_PORT: "5432"
DB_NAME: mastodon
DB_USER: mastodon
DB_PASS: ${POSTGRES_PASSWORD}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD}
SMTP_FROM_ADDRESS: notifications@yourdomain.com
volumes:
- mastodon_system:/mastodon/public/system
db:
image: postgres:16.4-alpine
restart: unless-stopped
environment:
POSTGRES_USER: mastodon
POSTGRES_DB: mastodon
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- mastodon_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mastodon"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7.2.5-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
env_file: .env
volumes:
- mastodon_redis_data:/data
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- mastodon
- streaming
volumes:
mastodon_system:
mastodon_db_data:
mastodon_redis_data:
caddy_data:
caddy_config:
This guide pins v4.7.3 across all three Mastodon images, the current stable release as of writing. The mastodon service’s command runs rails db:prepare before Puma starts, the same pattern Rails apps use generally, so schema setup happens automatically on first boot and on every later upgrade. streaming is a genuinely separate Node.js process, not a thread inside the Rails app, which is why it has its own image and its own STREAMING_API_BASE_URL pointed at from the main service. mastodon_system is a volume shared between mastodon and sidekiq specifically, since Sidekiq is what actually processes media uploads in the background; both need to see the same files.
Check the file parses:
docker compose config
Step 4: Add Caddy, including the streaming path
social.yourdomain.com {
@streaming path /api/v1/streaming*
reverse_proxy @streaming streaming:4000
reverse_proxy mastodon:3000
}
Mastodon’s web client calls /api/v1/streaming for its real-time timeline, which has to reach the separate streaming service, not the main Rails process; everything else goes to mastodon:3000. Validate the Caddyfile reads correctly before relying on it:
docker run --rm -v "$PWD/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile
Step 5: Boot the stack and create an admin account
docker compose up -d
docker compose ps
curl -I https://social.yourdomain.com
You should see every service Up (db as healthy) and the curl return HTTP/2 200. Create the first account from the command line, with administrator privileges:
docker compose exec mastodon bin/tootctl accounts create admin \
--email admin@yourdomain.com --confirmed --role Owner
This prints a generated password; log in with it and change it immediately from the web UI.
Step 6: Back up Postgres and the media volume
cat > /opt/mastodon/backup.sh <<'EOF'
#!/bin/sh
cd /opt/mastodon
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U mastodon mastodon | gzip > backups/db-$TIMESTAMP.sql.gz
find backups -mtime +14 -delete
EOF
mkdir -p /opt/mastodon/backups
chmod +x /opt/mastodon/backup.sh
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/mastodon/backup.sh") | crontab -
The mastodon_system volume holds every uploaded media attachment and grows without bound as people post; back it up separately with whatever full-disk or volume-snapshot tooling your server provider offers, a pg_dump alone doesn’t cover it. Run the script once by hand and check ls -lh backups shows today’s file.
Step 7: Upgrade
docker compose pull
docker compose up -d
db:prepare in the mastodon service’s command runs again on every restart, applying whatever migration shipped with the new version. Read Mastodon’s own release notes before a multi-version jump; the project occasionally calls out a required intermediate version for larger schema changes.
The shortcut: do it with Railyard
Everything above, Postgres and Redis wired to all three Rails-based processes, the streaming service split out with its own routing, Sidekiq sharing the media volume, is also what Railyard’s Mastodon stack template sets up for you. Connect a server, then pick Mastodon in the templates catalog: app.railyard.run/deploy?stack=mastodon-stack. Railyard generates every secret from Step 2 for you and wires the database, Redis and streaming URLs automatically; you still add your own SMTP provider afterward, the same as Step 2 above, Railyard doesn’t configure one for you either, and uploaded media still defaults to a shared local volume unless you point it at object storage yourself.
See deploy with a button and databases and backups for how templates and persistent data work on the platform more generally.
Troubleshooting
The real-time timeline never updates, but posting and browsing work fine. The Caddyfile isn’t routing /api/v1/streaming to the streaming service; check the path match in Step 4 against what your browser’s network tab actually requests.
No confirmation email ever arrives for a new sign-up. SMTP isn’t configured or the credentials are wrong; check docker compose logs mastodon for an SMTP error at the point registration was attempted. There’s no way around this requirement, Mastodon won’t confirm an account without it.
Sidekiq falls behind, posts from other instances arrive late. Check docker compose logs sidekiq for repeated job failures or an out-of-memory restart; on a CX33, this is usually resource contention with Postgres and the web process during a traffic spike, which is the point where splitting Postgres onto its own server starts to pay for itself.
HTTPS not issuing. docker compose logs caddy showing repeated ACME errors almost always means the DNS record from Step 1 hasn’t propagated or points at the wrong IP.
Disk filling up from the media volume. Mastodon caches remote media (other instances’ images and video) locally by default; tootctl media remove --days 30 from inside the mastodon container clears cached remote media older than a given window without touching your own users’ posts.
FAQ
Can I change my instance’s domain later? No, account handles are tied to the domain permanently; this is a fediverse-wide constraint, not something Mastodon’s own config can work around, decide on the real domain before inviting anyone.
Do I really need 8 GB of RAM for a tiny instance with a handful of users? The floor is lower, but once Postgres, Redis, the streaming server and Sidekiq all run on one box, 8 GB is where things stop being tight; a CX23 with 4 GB will technically boot this stack but leaves very little headroom for traffic spikes or federation storms.
What’s the difference between this and joining an existing Mastodon server? Running your own gets you your own moderation policy, your own domain, and full control over data retention; joining an existing instance trades that control for zero operational work.
Can object storage replace the local media volume? Yes, Mastodon supports S3-compatible storage for media through its own configuration; this guide keeps it local for a straightforward first setup, worth revisiting once media volume becomes the main disk consumer.
Is federation ever disabled by default? No, a fresh instance federates with the wider network as soon as it’s reachable over HTTPS; there’s a separate allowlist mode if you want a closed instance instead, not covered here.
A correctly running Mastodon instance is five services agreeing on one domain and one set of credentials, not one complicated thing. Once DNS, the streaming route and SMTP are right, the ongoing work is backups, the occasional version bump, and keeping an eye on Sidekiq under real traffic.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.