Skip to main content

How to Self-Host Mastodon 2026

·OSSAlt Team
mastodonfediverseself-hostingsocial-mediatwitter-alternative
Share:

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

Why Self-Host Mastodon?

The case for self-hosting Mastodon comes down to three practical factors: data ownership, cost at scale, and operational control.

Data ownership is the fundamental argument. When you use a SaaS version of any tool, your data lives on someone else's infrastructure subject to their terms of service, their security practices, and their business continuity. If the vendor raises prices, gets acquired, changes API limits, or shuts down, you're left scrambling. Self-hosting Mastodon means your data and configuration stay on infrastructure you control — whether that's a VPS, a bare metal server, or a home lab.

Cost at scale matters once you move beyond individual use. Most SaaS equivalents charge per user or per data volume. A self-hosted instance on a $10-20/month VPS typically costs less than per-user SaaS pricing for teams of five or more — and the cost doesn't scale linearly with usage. One well-configured server handles dozens of users for a flat monthly fee.

Operational control is the third factor. The Docker Compose configuration above exposes every setting that commercial equivalents often hide behind enterprise plans: custom networking, environment variables, storage backends, and authentication integrations. You decide when to update, how to configure backups, and what access controls to apply.

The honest tradeoff: you're responsible for updates, backups, and availability. For teams running any production workloads, this is familiar territory. For individuals, the learning curve is real but the tooling (Docker, Caddy, automated backups) is well-documented and widely supported.

Server Requirements and Sizing

Before deploying Mastodon, assess your server capacity against expected workload.

Minimum viable setup: A 1 vCPU, 1GB RAM VPS with 20GB SSD is sufficient for personal use or small teams. Most consumer VPS providers — Hetzner, DigitalOcean, Linode, Vultr — offer machines in this range for $5-10/month. Hetzner offers excellent price-to-performance for European and US regions.

Recommended production setup: 2 vCPUs with 4GB RAM and 40GB SSD handles most medium deployments without resource contention. This gives Mastodon headroom for background tasks, caching, and concurrent users while leaving capacity for other services on the same host.

Storage planning: The Docker volumes in this docker-compose.yml store all persistent Mastodon data. Estimate your storage growth rate early — for data-intensive tools, budget for 3-5x your initial estimate. Hetzner Cloud and Vultr both support online volume resizing without stopping your instance.

Operating system: Any modern 64-bit Linux distribution works. Ubuntu 22.04 LTS and Debian 12 are the most commonly tested configurations. Ensure Docker Engine 24.0+ and Docker Compose v2 are installed — verify with docker --version and docker compose version. Avoid Docker Desktop on production Linux servers; it adds virtualization overhead and behaves differently from Docker Engine in ways that cause subtle networking issues.

Network: Only ports 80 and 443 need to be publicly accessible when running behind a reverse proxy. Internal service ports should be bound to localhost only. A minimal UFW firewall that blocks all inbound traffic except SSH, HTTP, and HTTPS is the single most effective security measure for a self-hosted server.

Backup and Disaster Recovery

Running Mastodon without a tested backup strategy is an unacceptable availability risk. Docker volumes are not automatically backed up — if you delete a volume or the host fails, data is gone with no recovery path.

What to back up: The named Docker volumes containing Mastodon's data (database files, user uploads, application state), your docker-compose.yml and any customized configuration files, and .env files containing secrets.

Backup approach: For simple setups, stop the container, archive the volume contents, then restart. For production environments where stopping causes disruption, use filesystem snapshots or database dump commands (PostgreSQL pg_dump, SQLite .backup, MySQL mysqldump) that produce consistent backups without downtime.

For a complete automated backup workflow that ships snapshots to S3-compatible object storage, see the Restic + Rclone backup guide. Restic handles deduplication and encryption; Rclone handles multi-destination uploads. The same setup works for any Docker volume.

Backup cadence: Daily backups to remote storage are a reasonable baseline for actively used tools. Use a 30-day retention window minimum — long enough to recover from mistakes discovered weeks later. For critical data, extend to 90 days and use a secondary destination.

Restore testing: A backup that has never been restored is a backup you cannot trust. Once a month, restore your Mastodon backup to a separate Docker Compose stack on different ports and verify the data is intact. This catches silent backup failures, script errors, and volume permission issues before they matter in a real recovery.

Security Hardening

Self-hosting means you are responsible for Mastodon's security posture. The Docker Compose setup provides a functional base; production deployments need additional hardening.

Always use a reverse proxy: Never expose Mastodon's internal port directly to the internet. The docker-compose.yml binds to localhost; Caddy or Nginx provides HTTPS termination. Direct HTTP access transmits credentials in plaintext. A reverse proxy also centralizes TLS management, rate limiting, and access logging.

Strong credentials: Change default passwords immediately after first login. For secrets in docker-compose environment variables, generate random values with openssl rand -base64 32 rather than reusing existing passwords.

Firewall configuration:

ufw default deny incoming
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Internal service ports (databases, admin panels, internal APIs) should only be reachable from localhost or the Docker network, never directly from the internet.

Network isolation: Docker Compose named networks keep Mastodon's services isolated from other containers on the same host. Database containers should not share networks with containers that don't need direct database access.

VPN access for sensitive services: For internal-only tools, restricting access to a VPN adds a strong second layer. Headscale is an open source Tailscale control server that puts your self-hosted stack behind a WireGuard mesh, eliminating public internet exposure for internal tools.

Update discipline: Subscribe to Mastodon's GitHub releases page to receive security advisory notifications. Schedule a monthly maintenance window to pull updated images. Running outdated container images is the most common cause of self-hosted service compromises.

Troubleshooting Common Issues

Container exits immediately or won't start

Check logs first — they almost always explain the failure:

docker compose logs -f mastodon

Common causes: a missing required environment variable, a port already in use, or a volume permission error. Port conflicts appear as bind: address already in use. Find the conflicting process with ss -tlpn | grep PORT and either stop it or change Mastodon's port mapping in docker-compose.yml.

Cannot reach the web interface

Work through this checklist:

  1. Confirm the container is running: docker compose ps
  2. Test locally on the server: curl -I http://localhost:PORT
  3. If local access works but external doesn't, check your firewall: ufw status
  4. If using a reverse proxy, verify it's running and the config is valid: caddy validate --config /etc/caddy/Caddyfile

Permission errors on volume mounts

Some containers run as a non-root user. If the Docker volume is owned by root, the container process cannot write to it. Find the volume's host path with docker volume inspect VOLUME_NAME, check the tool's documentation for its expected UID, and apply correct ownership:

chown -R 1000:1000 /var/lib/docker/volumes/your_volume/_data

High resource usage over time

Memory or CPU growing continuously usually indicates unconfigured log rotation, an unbound cache, or accumulated data needing pruning. Check current usage with docker stats mastodon. Add resource limits in docker-compose.yml to prevent one container from starving others. For ongoing visibility into resource trends, deploy Prometheus + Grafana or Netdata.

Data disappears after container restart

Data stored in the container's writable layer — rather than a named volume — is lost when the container is removed or recreated. This happens when the volume mount path in docker-compose.yml doesn't match where the application writes data. Verify mount paths against the tool's documentation and correct the mapping. Named volumes persist across container removal; only docker compose down -v deletes them.

Keeping Mastodon Updated

Mastodon follows a regular release cadence. Staying current matters for security patches and compatibility. The update process with Docker Compose is straightforward:

docker compose pull          # Download updated images
docker compose up -d         # Restart with new images
docker image prune -f        # Remove old image layers (optional)

Read the changelog before major version updates. Some releases include database migrations or breaking configuration changes. For major version bumps, test in a staging environment first — run a copy of the service on different ports with the same volume data to validate the migration before touching production.

Version pinning: For stability, pin to a specific image tag in docker-compose.yml instead of latest. Update deliberately after reviewing the changelog. This trades automatic patch delivery for predictable behavior — the right call for business-critical services.

Post-update verification: After updating, confirm Mastodon is functioning correctly. Most services expose a /health endpoint that returns HTTP 200 — curl it from the server or monitor it with your uptime tool.


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

The SaaS-to-Self-Hosted Migration Guide (Free PDF)

Step-by-step: infrastructure setup, data migration, backups, and security for 15+ common SaaS replacements. Used by 300+ developers.

Join 300+ self-hosters. Unsubscribe in one click.