Mastodon mit Docker selbst hosten – inkl. hübscher Handles per Split-Domain

Getestet auf
  • Debian 13 (Trixie) / Docker Compose v2 / Mastodon v4.6.0 / 2026-06-18
TL;DR

Mit Docker Compose läuft Mastodon v4.6 in rund einer halben Stunde: fünf Container (web, streaming, sidekiq, PostgreSQL, Redis), davor ein Reverse-Proxy mit TLS. Der Split-Domain-Trick (LOCAL_DOMAIN vs. WEB_DOMAIN) gibt dir Handles wie @ADMIN_USER@DEINE_DOMAIN, obwohl der Server selbst auf einer Subdomain läuft.

Getestet auf

Debian 13 (Trixie), Docker Compose v2, Mastodon v4.6.0 – Stand 2026-06-18. Reverse-Proxy: nginx mit Let’s-Encrypt-Zertifikat. Reales Deployment hinter eigener Infrastruktur.

Voraussetzungen

  • Server mit Docker + Docker Compose v2, ~2 GB freiem RAM und ausreichend Plattenplatz (Medienspeicher wächst mit der Aktivität).
  • Zwei DNS-Namen, beide auf deinen Server bzw. Reverse-Proxy zeigend: die Handle-Domain und eine Subdomain für den Server.
  • Reverse-Proxy mit gültigem TLS-Zertifikat (hier nginx).
  • Optional, aber für Anmeldungen nötig: ein SMTP-Zugang.

Achtung, vorab entscheiden: LOCAL_DOMAIN und WEB_DOMAIN legst du vor dem ersten Start fest. Nachträglich ändern bricht die Föderation – der Rest des Fediverse kennt dann noch deine alten Adressen.

Platzhalter

Ersetze in allen Befehlen und Konfigurationen durchgehend dieselben Platzhalter durch deine Werte:

PlatzhalterBedeutungBeispiel
DEINE_DOMAINDomain für die Handles (@name@DEINE_DOMAIN)example.de
SERVER_DOMAINSubdomain, auf der Mastodon läuftsocial.example.de
ADMIN_USERdein Mastodon-Benutzernameadmin
ADMIN_EMAILE-Mail-Adresse deines Admin-Accountsadmin@example.de
DB_PASSWORTPasswort für die PostgreSQL-Datenbank(zufällig erzeugen)
MAIL_SERVERSMTP-Server (nur Schritt 9)mail.example.de
SMTP_USERSMTP-Login (nur Schritt 9)postfach@example.de
SMTP_PASSWORTSMTP-Passwort (nur Schritt 9)(aus deinem Mailserver)

Schritte

1. Verzeichnis und Compose-Datei

mkdir -p /opt/mastodon/{system,postgres,redis}
cd /opt/mastodon

docker-compose.yml (ersetze DB_PASSWORT):

name: mastodon
services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    healthcheck: {test: ["CMD","pg_isready","-U","mastodon"], interval: 10s, timeout: 5s, retries: 10}
    environment:
      POSTGRES_USER: mastodon
      POSTGRES_DB: mastodon
      POSTGRES_PASSWORD: "DB_PASSWORT"
    volumes: ["./postgres:/var/lib/postgresql/data"]
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    healthcheck: {test: ["CMD","redis-cli","ping"], interval: 10s, timeout: 5s, retries: 10}
    volumes: ["./redis:/data"]
  web:
    image: ghcr.io/mastodon/mastodon:v4.6.0
    restart: unless-stopped
    env_file: .env.production
    command: ["bundle","exec","puma","-C","config/puma.rb"]
    healthcheck: {test: ["CMD-SHELL","wget -q --tries=1 --spider http://localhost:3000/health || exit 1"], interval: 15s, timeout: 5s, retries: 16}
    depends_on: {db: {condition: service_healthy}, redis: {condition: service_healthy}}
    ports: ["127.0.0.1:3000:3000"]
    volumes: ["./system:/mastodon/public/system"]
  streaming:
    image: ghcr.io/mastodon/mastodon-streaming:v4.6.0
    restart: unless-stopped
    env_file: .env.production
    healthcheck: {test: ["CMD-SHELL","wget -q --tries=1 --spider http://localhost:4000/api/v1/streaming/health || exit 1"], interval: 15s, timeout: 5s, retries: 16}
    depends_on: {db: {condition: service_healthy}, redis: {condition: service_healthy}}
    ports: ["127.0.0.1:4000:4000"]
  sidekiq:
    image: ghcr.io/mastodon/mastodon:v4.6.0
    restart: unless-stopped
    env_file: .env.production
    command: ["bundle","exec","sidekiq","-c","10"]
    depends_on: {db: {condition: service_healthy}, redis: {condition: service_healthy}}
    volumes: ["./system:/mastodon/public/system"]

Hinweis: Seit Mastodon 4.3 ist der Streaming-Server ein eigenes Image (mastodon-streaming) – nicht das mastodon-Image dafür verwenden.

2. Grund-Konfiguration .env.production

Ersetze DEINE_DOMAIN, SERVER_DOMAIN und DB_PASSWORT:

LOCAL_DOMAIN=DEINE_DOMAIN
WEB_DOMAIN=SERVER_DOMAIN
RAILS_ENV=production
NODE_ENV=production
REDIS_HOST=redis
REDIS_PORT=6379
DB_HOST=db
DB_PORT=5432
DB_NAME=mastodon
DB_USER=mastodon
DB_PASS=DB_PASSWORT
SINGLE_USER_MODE=false
WEB_CONCURRENCY=1
MAX_THREADS=5

DB_PASS muss identisch zu POSTGRES_PASSWORD aus Schritt 1 sein.

3. Secrets erzeugen

SECRET_KEY_BASE und OTP_SECRET sind je ein 128-Zeichen-Hex-String (openssl rand -hex 64 erzeugt 64 Bytes = 128 Hex-Zeichen), die ActiveRecord-Encryption-Keys je 32 Hex-Zeichen:

{
  echo "SECRET_KEY_BASE=$(openssl rand -hex 64)"
  echo "OTP_SECRET=$(openssl rand -hex 64)"
  echo "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(openssl rand -hex 16)"
  echo "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(openssl rand -hex 16)"
  echo "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(openssl rand -hex 16)"
} >> .env.production

Die VAPID-Schlüssel (Web-Push) erzeugt Mastodon selbst:

docker compose run --rm -T web bundle exec rake mastodon:webpush:generate_vapid_key </dev/null \
  | grep -E '^VAPID_(PRIVATE|PUBLIC)_KEY=' >> .env.production

Beim Skripten wichtig: docker compose run liest stdin. In einem Heredoc-Skript „frisst" es sonst die restlichen Zeilen – gib ihm </dev/null.

4. Datenbank initialisieren

docker compose up -d db redis
docker compose run --rm -T web bundle exec rails db:setup </dev/null

5. Owner-Account anlegen

Ersetze ADMIN_USER und ADMIN_EMAIL:

docker compose run --rm -T web bin/tootctl accounts create ADMIN_USER \
  --email ADMIN_EMAIL --confirmed --role Owner </dev/null

Das gibt ein Initialpasswort aus – notier es sofort. Wenn du nach dem ersten Login im gelben Balken „pending review" hängst und immer wieder auf die Account-Seite umgeleitet wirst: Der Account ist bestätigt, aber noch nicht freigegeben. Freigeben:

docker compose run --rm -T web bin/tootctl accounts approve ADMIN_USER </dev/null

6. Stack starten

docker compose up -d

Der erste Start dauert etwas, weil Puma hochfährt und Assets kompiliert werden. docker compose ps zeigt healthy, sobald alle Container bereit sind.

7. Reverse-Proxy (nginx)

Streaming läuft auf einem eigenen Port – /api/v1/streaming muss dorthin, der Rest auf web. Ersetze SERVER_DOMAIN:

server {
    listen 443 ssl;
    server_name SERVER_DOMAIN;
    # ssl_certificate / ssl_certificate_key ...
    client_max_body_size 80M;

    location /api/v1/streaming {
        proxy_pass http://127.0.0.1:4000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

8. Hübsche Handles per Split-Domain

Damit dein Handle @ADMIN_USER@DEINE_DOMAIN lautet (statt @ADMIN_USER@SERVER_DOMAIN), muss DEINE_DOMAIN drei Pfade dauerhaft auf die Server-Subdomain umleiten – nur diese drei, niemals /api/.... Ersetze DEINE_DOMAIN und SERVER_DOMAIN:

# Auf dem vHost von DEINE_DOMAIN:
location = /.well-known/host-meta { return 301 https://SERVER_DOMAIN$request_uri; }
location = /.well-known/webfinger  { return 301 https://SERVER_DOMAIN$request_uri; }
location = /.well-known/nodeinfo   { return 301 https://SERVER_DOMAIN$request_uri; }

Test (ersetze DEINE_DOMAIN und ADMIN_USER):

curl -sL "https://DEINE_DOMAIN/.well-known/webfinger?resource=acct:ADMIN_USER@DEINE_DOMAIN"

Die Antwort muss "subject":"acct:ADMIN_USER@DEINE_DOMAIN" enthalten.

9. Mailversand (optional, aber für Anmeldungen nötig)

Ohne SMTP funktionieren Registrierungen anderer nicht (keine Bestätigungsmail). In .env.production ergänzen (ersetze MAIL_SERVER, SMTP_USER, SMTP_PASSWORT):

SMTP_SERVER=MAIL_SERVER
SMTP_PORT=465
SMTP_LOGIN=SMTP_USER
SMTP_PASSWORD=SMTP_PASSWORT
SMTP_FROM_ADDRESS=Mastodon <SMTP_USER>
SMTP_TLS=true
SMTP_AUTH_METHOD=plain
SMTP_OPENSSL_VERIFY_MODE=peer

Für STARTTLS auf Port 587 statt SMTPS auf 465: SMTP_PORT=587, Zeile SMTP_TLS entfernen und SMTP_ENABLE_STARTTLS=always setzen. Danach docker compose up -d web sidekiq.

10. Plattenplatz im Griff behalten

Mastodon cached fremde Medien – das wächst unkontrolliert. Per Cron wöchentlich aufräumen:

# /etc/cron.d/mastodon-media-cleanup
0 4 * * 0 root cd /opt/mastodon && docker compose run --rm -T web bin/tootctl media remove --days 14 >/dev/null 2>&1

Troubleshooting

  • „pending review", Endlosschleife auf die Account-Seite: Der Account ist nicht freigegeben → tootctl accounts approve ADMIN_USER (siehe Schritt 5).
  • Bind for 127.0.0.1:3000 failed: port is already allocated: Ein anderer Dienst belegt den Port. Host-Port im Compose ändern (z. B. 127.0.0.1:3001:3000) und im Reverse-Proxy entsprechend.
  • docker compose run bricht dein Skript ab: Es liest stdin und frisst den Rest deines Heredocs → </dev/null anhängen.
  • Handle bleibt @ADMIN_USER@SERVER_DOMAIN: Die well-known-Redirects auf DEINE_DOMAIN fehlen oder zeigen falsch (Schritt 8). /api/... darf nicht umgeleitet werden.
  • Domains nachträglich ändern: Sauber nicht möglich, sobald föderiert wurde – vorher festlegen.

Changelog

  • 2026-06-18 – Erstveröffentlichung.