I’ve been running a Mastodon instance for a while now, and every time I set one up from scratch I end up digging through documentation that’s either outdated or assumes you already know what you’re doing. This is the guide I wish I’d had.
What you need
- A server with Docker and Docker Compose installed
- A domain name that points to your server’s IP address
- Somewhere to send email (Mailgun, SendGrid, or your own SMTP server)
That’s it. No Kubernetes, no systemd fiddling, no compiling from source.
The compose file
Create a directory and drop this in as docker-compose.yml:
version: '3'
services:
db:
restart: always
image: postgres:14-alpine
shm_size: 256mb
networks:
- internal_network
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_HOST_AUTH_METHOD=trust
redis:
restart: always
image: redis:7-alpine
networks:
- internal_network
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
volumes:
- redis_data:/data
web:
restart: always
image: tootsuite/mastodon:latest
networks:
- external_network
- internal_network
healthcheck:
test: ['CMD-SHELL', 'wget -q --spider http://localhost:3000/health || exit 1']
timeout: 10s
depends_on:
- db
- redis
environment:
- DB_HOST=db
- DB_USER=postgres
- DB_NAME=postgres
- DB_PASS=
- REDIS_HOST=redis
- LOCAL_DOMAIN=your-domain.com
- SINGLE_USER_MODE=false
- SMTP_SERVER=smtp.mailgun.org
- SMTP_PORT=587
- SMTP_LOGIN=your-smtp-login
- SMTP_PASSWORD=your-smtp-password
- SMTP_FROM_ADDRESS=notifications@your-domain.com
volumes:
- ./public/system:/mastodon/public/system
ports:
- "3000:3000"
command: bash -c "rm -f /mastodon/tmp/pids/server.pid && bundle exec rails s -p 3000"
streaming:
restart: always
image: tootsuite/mastodon:latest
networks:
- external_network
- internal_network
depends_on:
- db
- redis
environment:
- DB_HOST=db
- DB_USER=postgres
- DB_NAME=postgres
- DB_PASS=
- REDIS_HOST=redis
- LOCAL_DOMAIN=your-domain.com
volumes:
- ./public/system:/mastodon/public/system
ports:
- "4000:4000"
command: node ./streaming
sidekiq:
restart: always
image: tootsuite/mastodon:latest
networks:
- internal_network
depends_on:
- db
- redis
environment:
- DB_HOST=db
- DB_USER=postgres
- DB_NAME=postgres
- DB_PASS=
- REDIS_HOST=redis
- LOCAL_DOMAIN=your-domain.com
volumes:
- ./public/system:/mastodon/public/system
command: bundle exec sidekiq
networks:
external_network:
internal_network:
internal: true
volumes:
postgres_data:
redis_data:
A few things worth noting: the internal: true on the internal network means those containers can’t reach the outside world directly, which is a nice security boundary. The shm_size: 256mb on Postgres is important – without it you’ll hit shared memory errors under load.
Environment variables
Create a .env.production file alongside the compose file:
# Database
DB_HOST=db
DB_PORT=5432
DB_NAME=postgres
DB_USER=postgres
DB_PASS=
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
# Mastodon
LOCAL_DOMAIN=your-domain.com
SECRET_KEY_BASE=generate-a-secret-key
OTP_SECRET=generate-an-otp-secret
# Web Push
VAPID_PRIVATE_KEY=generate-vapid-private-key
VAPID_PUBLIC_KEY=generate-vapid-public-key
# SMTP (Email)
SMTP_SERVER=smtp.mailgun.org
SMTP_PORT=587
SMTP_LOGIN=your-email-login
SMTP_PASSWORD=your-email-password
SMTP_AUTH_METHOD=plain
SMTP_OPENSSL_VERIFY_MODE=none
SMTP_FROM_ADDRESS=notifications@your-domain.com
# Optional: S3 storage for media
# S3_ENABLED=true
# S3_BUCKET=your-bucket-name
# AWS_ACCESS_KEY_ID=your-access-key
# AWS_SECRET_ACCESS_KEY=your-secret-key
# S3_REGION=us-east-1
# S3_PROTOCOL=https
# S3_HOSTNAME=s3.amazonaws.com
I’ve left the S3 section commented out by default. You only need it if you want media stored off-server – otherwise Mastodon writes to disk just fine.
Generating secrets
You need a few random strings before you can start the containers:
docker run --rm tootsuite/mastodon:latest bundle exec rake secret
docker run --rm tootsuite/mastodon:latest bundle exec rake mastodon:webpush:generate_vapid_key
The first command gives you both SECRET_KEY_BASE and OTP_SECRET – run it twice if you want separate values. The second gives you the VAPID keys for web push notifications. Drop these into your .env.production file.
Getting it running
# Pull the images
docker-compose pull
# Start everything up
docker-compose up -d
# Set up the database
docker-compose run --rm web bundle exec rake db:migrate
# Compile the assets (this takes a while)
docker-compose run --rm web bundle exec rake assets:precompile
# Create your admin account
docker-compose run --rm web bundle exec rake mastodon:setup
The mastodon:setup command will walk you through creating an admin user interactively. Pay attention to the email domain – it needs to match your SMTP configuration.
Nginx in front
Mastodon isn’t designed to be exposed directly to the internet. You need a reverse proxy, and Nginx is the standard choice:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name your-domain.com;
# Uncomment for SSL
# listen 443 ssl http2;
# listen [::]:443 ssl http2;
# ssl_certificate /path/to/certificate.crt;
# ssl_certificate_key /path/to/private.key;
root /home/mastodon/live/public;
location / {
try_files $uri @proxy;
}
location ~ ^/(emoji|packs|system/accounts/avatars|system/media_attachments/files) {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri @proxy;
}
location /sw.js {
add_header Cache-Control "public, max-age=0";
try_files $uri @proxy;
}
location @proxy {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Proxy "";
proxy_pass_header Server;
proxy_pass http://127.0.0.1:3000;
proxy_buffering off;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
add_header Strict-Transport-Security "max-age=31536000";
}
location /api/v1/streaming {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Proxy "";
proxy_pass http://127.0.0.1:4000;
proxy_buffering off;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
error_page 500 501 502 503 504 /500.html;
}
Get SSL set up with Let’s Encrypt once you’ve confirmed the plain HTTP version works. The Strict-Transport-Security header is already in the config – uncomment the SSL lines and you’re good.
Keeping it alive
Once it’s running, here are the commands you’ll actually use:
# Watch logs from all services
docker-compose logs -f
# Watch a specific service
docker-compose logs -f web
docker-compose logs -f sidekiq
# Stop everything
docker-compose down
# Backup the database
docker-compose exec db pg_dump -U postgres postgres > backup-$(date +%Y%m%d).sql
# Update to the latest version
docker-compose pull
docker-compose run --rm web bundle exec rake db:migrate
docker-compose run --rm web bundle exec rake assets:precompile
docker-compose restart
Things I learned the hard way
Mastodon eats RAM. A bare-minimum instance needs at least 2GB, ideally 4GB. If you’re seeing OOM kills, that’s your first clue.
The assets precompile step is slow. Don’t skip it, and don’t be alarmed when it takes several minutes. It only runs when things change, so it’s a one-time cost on updates.
Back up your database regularly. There’s no built-in backup mechanism. A simple cron job running the pg_dump command above will save you from a world of pain.
SMTP matters more than you think. If your email isn’t working, users can’t verify accounts or reset passwords, and your instance becomes effectively unusable for anyone but the admin. Test it early.
The streaming service is why WebSocket support matters. If your reverse proxy doesn’t handle upgrades properly, real-time updates won’t work and your timeline will feel broken. The Nginx config above handles this, but if you’re using something else, double-check the Upgrade and Connection headers.
What’s next
This gets you running. From here you might want to add S3 for media storage, set up automated backups, configure rate limiting, or join the Fediverse properly by following other instances. But that’s a different post.