How to Self-Host Mastodon 2026
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=trueto 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
| Tool | Language | RAM Usage | Best For |
|---|---|---|---|
| GoToSocial | Go | ~100MB | Personal instances, minimal setup |
| Pleroma/Akkoma | Elixir | ~200MB | Small communities, Mastodon-compatible |
| Misskey/Calckey | Node.js | ~500MB | Feature-rich, emoji reactions, Kanban |
| Mastodon | Ruby | ~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
| Approach | Monthly Cost | Notes |
|---|---|---|
| Mastodon.social account | Free | Limited storage, shared moderation |
| Small Hetzner VPS + setup | ~€5-8/mo | Full control, requires maintenance |
| Managed (masto.host) | $5-19/mo | Hands-off, limited customization |
| Dedicated server (large instance) | $30-100/mo | 1000+ 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_HOSTmatches your CDN/bucket domain - Run
docker compose run --rm web bin/tootctl media refreshto 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