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.