Skip to main content

How to Self-Host Mastodon 2026

·OSSAlt Team
mastodonfediverseself-hostingsocial-mediatwitter-alternative

How to Self-Host Mastodon 2026

TL;DR

Mastodon is the leading open source, decentralized social network — a drop-in replacement for Twitter/X where you own your data, set your own rules, and optionally federate with the wider Fediverse. Self-hosting takes about 30 minutes with Docker Compose and requires a VPS with at least 2GB RAM, a domain, and object storage for media. This guide covers everything from initial setup to production hardening, email delivery, and federation.

Key Takeaways

  • Mastodon runs on Ruby on Rails + Sidekiq + Elasticsearch (optional) + PostgreSQL + Redis — all containerized via Docker Compose
  • Minimum server: 2 vCPU / 4GB RAM for a small personal/community instance (Hetzner CX22 at €4.15/mo works)
  • Media storage: Local disk fills fast — configure S3-compatible object storage (MinIO, Backblaze B2, or AWS S3) from day one
  • Federation: Your instance automatically federates with the Fediverse — your users can follow anyone on any Mastodon/ActivityPub instance
  • Moderation: Built-in tools for reports, suspensions, defederation (blocking entire domains), and content warnings
  • Alternatives: If Mastodon feels heavy, consider Misskey/Calckey (more features), Pleroma/Akkoma (lighter), or GoToSocial (Go-based, minimal RAM)

Why Self-Host Mastodon?

Mastodon.social and similar large instances can be slow, have long registration queues, or implement policies you disagree with. A self-hosted instance gives you:

  • Full control over your data, moderation policies, and federation choices
  • Custom domain — your identity is @you@yourdomain.com, never tied to a third-party instance
  • Community branding — run an instance for your organization, community, or topic niche
  • No rate limits — set your own rules on post length, media uploads, and API access
  • Data portability — export and migrate your account if you ever move to a new host

The Fediverse has grown substantially: as of early 2026, Mastodon has 13M+ registered accounts across 12,000+ instances. Your self-hosted instance can follow and be followed by users on any of them.


Prerequisites

  • VPS: 2 vCPU / 4GB RAM minimum (Hetzner CX22, DigitalOcean Basic, or similar)
  • Domain: A domain you control (e.g., social.example.com) — identity URLs can't change later
  • Email: SMTP credentials for transactional email (Resend, Mailgun, or self-hosted Postal)
  • Object storage: S3-compatible bucket for media (or sufficient local disk — 50GB+ per year for active instances)
  • Docker + Docker Compose: Installed on the server

Server Setup

1. Create a Non-Root User

adduser mastodon
usermod -aG sudo mastodon
usermod -aG docker mastodon
su - mastodon

2. Clone Mastodon and Create Directories

git clone https://github.com/mastodon/mastodon.git /home/mastodon/live
cd /home/mastodon/live
mkdir -p public/system  # local media storage (if not using S3)

Docker Compose Configuration

Create /home/mastodon/live/docker-compose.yml:

version: '3'
services:
  db:
    restart: always
    image: postgres:16-alpine
    shm_size: 256mb
    environment:
      POSTGRES_HOST_AUTH_METHOD: trust
    volumes:
      - ./postgres14:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]

  redis:
    restart: always
    image: redis:7-alpine
    volumes:
      - ./redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]

  web:
    image: ghcr.io/mastodon/mastodon:latest
    restart: always
    env_file: .env.production
    command: bundle exec puma -C config/puma.rb
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ./public/system:/mastodon/public/system

  streaming:
    image: ghcr.io/mastodon/mastodon-streaming:latest
    restart: always
    env_file: .env.production
    command: node ./streaming
    ports:
      - "127.0.0.1:4000:4000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

  sidekiq:
    image: ghcr.io/mastodon/mastodon:latest
    restart: always
    env_file: .env.production
    command: bundle exec sidekiq
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ./public/system:/mastodon/public/system
    healthcheck:
      test: ["CMD-SHELL", "ps aux | grep '[s]idekiq 6' || false"]

volumes:
  postgres14:
  redis:

Environment Configuration

Generate the required secrets, then create .env.production:

# Generate secrets (run these and copy outputs)
docker run --rm ghcr.io/mastodon/mastodon:latest bundle exec rake secret
docker run --rm ghcr.io/mastodon/mastodon:latest bundle exec rake secret
docker run --rm ghcr.io/mastodon/mastodon:latest bundle exec rails db:encryption_key  # if using encrypted columns
# .env.production
LOCAL_DOMAIN=social.example.com
SINGLE_USER_MODE=false
SECRET_KEY_BASE=<generated-secret-1>
OTP_SECRET=<generated-secret-2>
VAPID_PRIVATE_KEY=<vapid-private>
VAPID_PUBLIC_KEY=<vapid-public>

# Database
DB_HOST=db
DB_PORT=5432
DB_NAME=mastodon_production
DB_USER=mastodon
DB_PASS=<strong-password>

# Redis
REDIS_HOST=redis
REDIS_PORT=6379

# Email (using Resend or SMTP)
SMTP_SERVER=smtp.resend.com
SMTP_PORT=587
SMTP_LOGIN=resend
SMTP_PASSWORD=<your-resend-api-key>
SMTP_FROM_ADDRESS=notifications@social.example.com
SMTP_AUTH_METHOD=plain
SMTP_OPENSSL_VERIFY_MODE=none

# S3 / Object Storage (recommended — use MinIO or Backblaze B2)
S3_ENABLED=true
S3_BUCKET=mastodon-media
S3_REGION=us-east-1
S3_ENDPOINT=https://s3.us-east-1.backblazeb2.com  # or MinIO URL
AWS_ACCESS_KEY_ID=<your-key>
AWS_SECRET_ACCESS_KEY=<your-secret>
S3_ALIAS_HOST=media.social.example.com  # optional CDN domain

# Optional: ElasticSearch for full-text search
# ES_ENABLED=true
# ES_HOST=es
# ES_PORT=9200

Database Setup and Initial Admin

# Initialize database
docker compose run --rm web bundle exec rails db:migrate
docker compose run --rm web bundle exec rails db:seed

# Start all services
docker compose up -d

# Create admin account
docker compose run --rm web bin/tootctl accounts create \
  admin \
  --email admin@example.com \
  --confirmed \
  --role Owner

Nginx Reverse Proxy

Create /etc/nginx/sites-available/mastodon:

map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

server {
  listen 80;
  server_name social.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name social.example.com;

  ssl_certificate /etc/letsencrypt/live/social.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/social.example.com/privkey.pem;
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers HIGH:!aNULL:!MD5;

  keepalive_timeout 70;
  sendfile on;
  client_max_body_size 99m;

  root /home/mastodon/live/public;
  gzip on;
  gzip_disable "msie6";
  gzip_vary on;
  gzip_proxied any;
  gzip_comp_level 6;
  gzip_types text/plain text/css application/json application/javascript text/javascript image/svg+xml image/x-icon;

  location / {
    try_files $uri @proxy;
  }

  location = /sw.js    { add_header Cache-Control "public, max-age=604800, must-revalidate"; try_files $uri @proxy; }
  location ~ ^/assets/  { add_header Cache-Control "public, max-age=2419200, must-revalidate"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; try_files $uri =404; }
  location ~ ^/avatars/ { add_header Cache-Control "public, max-age=2419200, must-revalidate"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; try_files $uri =404; }
  location ~ ^/emoji/   { add_header Cache-Control "public, max-age=2419200, must-revalidate"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; try_files $uri =404; }
  location ~ ^/headers/ { add_header Cache-Control "public, max-age=2419200, must-revalidate"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; try_files $uri =404; }
  location ~ ^/packs/   { add_header Cache-Control "public, max-age=2419200, must-revalidate"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; try_files $uri =404; }
  location ~ ^/sounds/  { add_header Cache-Control "public, max-age=2419200, must-revalidate"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; try_files $uri =404; }
  location ~ ^/system/  { add_header Cache-Control "public, max-age=2419200, must-revalidate"; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains"; try_files $uri =404; }

  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;
    tcp_nodelay on;
  }

  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 on;
    proxy_redirect off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_cache CACHE;
    proxy_cache_valid 200 7d;
    proxy_cache_valid 410 24h;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    add_header X-Cached $upstream_cache_status;
    tcp_nodelay on;
  }

  error_page 500 501 502 503 504 /500.html;
}
# Get SSL certificate
certbot --nginx -d social.example.com
ln -s /etc/nginx/sites-available/mastodon /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

Maintenance Tasks

Run these regularly (add to cron or a systemd timer):

# Daily: remove old media cache
docker compose run --rm web bin/tootctl media remove --days=7

# Daily: remove old preview cards
docker compose run --rm web bin/tootctl preview_cards remove --days=14

# Weekly: clean up orphaned statuses and accounts
docker compose run --rm web bin/tootctl statuses remove --days=30

# Monthly: database vacuum
docker compose exec db psql -U mastodon -c "VACUUM ANALYZE;"

Automate with crontab:

0 3 * * * cd /home/mastodon/live && docker compose run --rm web bin/tootctl media remove --days=7 >> /var/log/mastodon-cleanup.log 2>&1
0 4 * * 0 cd /home/mastodon/live && docker compose run --rm web bin/tootctl preview_cards remove --days=14 >> /var/log/mastodon-cleanup.log 2>&1

Moderation and Instance Settings

Access the admin panel at https://social.example.com/admin. Key settings:

Instance customization:

  • Set instance rules (displayed at signup and in client apps)
  • Add a custom emoji and pinned posts
  • Configure sign-up mode: open, invite-only, or approval-required

Federation controls:

  • Silence a domain: posts from that instance don't appear in your public timeline but federation continues
  • Suspend a domain (defederate): completely block federation with an instance
  • Allow-list mode: only federate with explicitly approved instances (for private/corporate deployments)

Content moderation:

  • Review reported posts and accounts from the Reports dashboard
  • Set up moderation roles for trusted users
  • Enable AUTHORIZED_FETCH=true to require authentication for fetching public posts (reduces spam crawlers)

Upgrading Mastodon

cd /home/mastodon/live

# Pull latest images
docker compose pull

# Run migrations before starting
docker compose run --rm web bundle exec rails db:migrate

# Restart all services
docker compose up -d --force-recreate

Always check the Mastodon release notes before upgrading — some releases require manual migration steps.


Lightweight Alternatives

ToolLanguageRAM UsageBest For
GoToSocialGo~100MBPersonal instances, minimal setup
Pleroma/AkkomaElixir~200MBSmall communities, Mastodon-compatible
Misskey/CalckeyNode.js~500MBFeature-rich, emoji reactions, Kanban
MastodonRuby~1GB+Full features, best client support

GoToSocial is the recommended choice if you're running a personal single-user instance — it uses a fraction of the RAM, implements the ActivityPub protocol, and is compatible with all Mastodon clients.


Cost Comparison

ApproachMonthly CostNotes
Mastodon.social accountFreeLimited storage, shared moderation
Small Hetzner VPS + setup~€5-8/moFull control, requires maintenance
Managed (masto.host)$5-19/moHands-off, limited customization
Dedicated server (large instance)$30-100/mo1000+ users, full infrastructure

Troubleshooting Common Issues

Sidekiq jobs backing up:

# Check queue depths
docker compose exec redis redis-cli llen sidekiq:queue:default
docker compose exec redis redis-cli llen sidekiq:queue:push
docker compose exec redis redis-cli llen sidekiq:queue:mailers
# If queues are deep, you may need more Sidekiq workers
# Scale: add RAILS_MAX_THREADS=10 to .env.production

Media files not loading:

  • Verify S3 bucket permissions allow public reads for media objects
  • Check S3_ALIAS_HOST matches your CDN/bucket domain
  • Run docker compose run --rm web bin/tootctl media refresh to re-fetch remote media

Federation not working:

# Test ActivityPub discovery
curl -H "Accept: application/activity+json" https://social.example.com/.well-known/webfinger?resource=acct:admin@social.example.com

# Check if your domain is blocked by major instances
# Search for your domain at instances.social

Database connection errors:

# Check postgres is healthy
docker compose ps db
docker compose logs db --tail=50
# If DB is full, check disk usage
docker compose exec db psql -U mastodon -c "SELECT pg_size_pretty(pg_database_size('mastodon_production'));"

Getting your first followers: Once your instance is live, post an introduction and tag it with #introduction. Submit your instance to instances.social to appear in the instance directory. Follow accounts from larger instances — many will follow back, establishing the first federation links that bring posts into your home feed.

Methodology

  • Mastodon documentation: docs.joinmastodon.org
  • Docker images: ghcr.io/mastodon/mastodon
  • Tested on Hetzner CX32 (4 vCPU / 8GB RAM), Docker 27, Mastodon 4.3

Browse open source alternatives to Twitter and social platforms on OSSAlt.

Related: How to Self-Host Matrix Synapse — Decentralized Messaging 2026 · Best Open Source Alternatives to Slack 2026

Comments