Skip to content

2026-09-02

Self-Hosting Mastodon on Kubernetes

Running your own Mastodon instance is one of the most rewarding ways to participate in the fediverse. You control your data, your rules, and your uptime. This article walks through how I run m6n.ca on a small Kubernetes cluster using GitOps principles, with an eye toward keeping things simple, maintainable, and easy to recover.

What we're building

At a high level, the setup looks like any typical Mastodon deployment:

  • Mastodon (the Rails web app + streaming API)
  • PostgreSQL (database)
  • Redis (caching and Sidekiq)
  • S3-compatible object storage (media attachments)
  • SMTP (email notifications)

The difference is that everything lives as declarative YAML in Git, managed by Argo CD. If the cluster evaporates tomorrow, I can recreate the entire instance from scratch in minutes.

The GitOps approach

I use Argo CD to manage all workloads. The Mastodon application is defined as an Argo CD Application resource that pulls the bjw-s app-template Helm chart. This chart is excellent for self-hosted services because it lets you define controllers, services, persistence, and ingress in a single structured values block.

Here's the gist of the app definition:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: m6n-mastodon-app
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "0"
spec:
  project: m6n
  source:
    repoURL: https://bjw-s.github.io/helm-charts/
    chart: app-template
    targetRevision: 5.1.0
    helm:
      values: |
        controllers:
          mastodon:
            type: deployment
            replicas: 1
            strategy: Recreate
            containers:
              mastodon:
                image:
                  repository: ghcr.io/linuxserver/mastodon
                  tag: "4.7.1"
                env:
                  AWS_ACCESS_KEY_ID:
                    valueFrom:
                      secretKeyRef:
                        name: mastodon-credentials
                        key: S3_ACCESS_KEY_ID
                  AWS_SECRET_ACCESS_KEY:
                    valueFrom:
                      secretKeyRef:
                        name: mastodon-credentials
                        key: S3_SECRET_ACCESS_KEY
                  TRUSTED_PROXY_IP: "<your-trusted-proxy-cidrs>"
                envFrom:
                  - configMapRef:
                      name: mastodon-shared-env
                  - secretRef:
                      name: mastodon-credentials
                probes:
                  liveness:
                    enabled: true
                    type: HTTPS
                    path: /health
                    port: 443
                    spec:
                      initialDelaySeconds: 60
                      periodSeconds: 10
                      timeoutSeconds: 5
                      failureThreshold: 3
                  readiness:
                    enabled: true
                    type: HTTPS
                    path: /health
                    port: 443
                    spec:
                      initialDelaySeconds: 30
                      periodSeconds: 5
                      timeoutSeconds: 3
                      failureThreshold: 3
                resources:
                  requests:
                    memory: 1Gi
                  limits:
                    memory: 2Gi

          mastodondb:
            type: deployment
            replicas: 1
            strategy: Recreate
            containers:
              postgres:
                image:
                  repository: postgres
                  tag: "18.6"
                env:
                  POSTGRES_DB: <your-db-name>
                  POSTGRES_USER: <your-db-user>
                  POSTGRES_PASSWORD:
                    valueFrom:
                      secretKeyRef:
                        name: mastodon-credentials
                        key: DB_PASS
                probes:
                  liveness:
                    enabled: true
                    type: AUTO
                    custom: true
                    spec:
                      exec:
                        command:
                          - pg_isready
                          - -U
                          - <your-db-user>
                          - -d
                          - <your-db-name>
                      initialDelaySeconds: 30
                      periodSeconds: 10
                      timeoutSeconds: 5
                      failureThreshold: 3
                  readiness:
                    enabled: true
                    type: AUTO
                    custom: true
                    spec:
                      exec:
                        command:
                          - pg_isready
                          - -U
                          - <your-db-user>
                          - -d
                          - <your-db-name>
                      initialDelaySeconds: 5
                      periodSeconds: 5
                      timeoutSeconds: 3
                      failureThreshold: 3
                resources:
                  requests:
                    memory: 512Mi
                  limits:
                    memory: 1Gi

          mastodonredis:
            type: deployment
            replicas: 1
            strategy: Recreate
            containers:
              redis:
                image:
                  repository: redis
                  tag: "8.10.1"
                args:
                  - --save
                  - ""
                  - --appendonly
                  - "no"
                probes:
                  liveness:
                    enabled: true
                    type: AUTO
                    custom: true
                    spec:
                      exec:
                        command:
                          - redis-cli
                          - ping
                      initialDelaySeconds: 10
                      periodSeconds: 10
                      timeoutSeconds: 5
                      failureThreshold: 3
                  readiness:
                    enabled: true
                    type: AUTO
                    custom: true
                    spec:
                      exec:
                        command:
                          - redis-cli
                          - ping
                      initialDelaySeconds: 5
                      periodSeconds: 5
                      timeoutSeconds: 3
                      failureThreshold: 3
                resources:
                  requests:
                    memory: 128Mi
                  limits:
                    memory: 256Mi

        service:
          app:
            controller: mastodon
            type: ClusterIP
            ports:
              http:
                port: 80
                targetPort: 80
                protocol: TCP
              https:
                port: 443
                targetPort: 443
                protocol: TCP
          mastodondb:
            controller: mastodondb
            type: ClusterIP
            ports:
              postgresql:
                port: 5432
                targetPort: 5432
                protocol: TCP
          mastodonredis:
            controller: mastodonredis
            type: ClusterIP
            ports:
              redis:
                port: 6379
                targetPort: 6379
                protocol: TCP

        persistence:
          config:
            type: persistentVolumeClaim
            accessMode: ReadWriteOnce
            size: 10Gi
            storageClass: local-hostpath-retain
            advancedMounts:
              mastodon:
                mastodon:
                  - path: /config
          nginx-config:
            type: configMap
            name: m6n-mastodon-nginx-config
            advancedMounts:
              mastodon:
                mastodon:
                  - path: /config/nginx/site-confs/default.conf
                    subPath: default.conf
          postgresql-18-data:
            type: persistentVolumeClaim
            accessMode: ReadWriteOnce
            size: 10Gi
            storageClass: local-hostpath-retain
            advancedMounts:
              mastodondb:
                postgres:
                  - path: /var/lib/postgresql

  destination:
    server: https://kubernetes.default.svc
    namespace: m6n

  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=false
    retry:
      limit: 5
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m

A few deliberate choices here:

  • Single replica, Recreate strategy: Mastodon isn't horizontally safe out of the box (Sidekiq and streaming have assumptions about singletons), so I run one pod and let Kubernetes replace it cleanly on updates.
  • linuxserver.io image: It bundles nginx, the Rails app, and the streaming API in one container, which simplifies networking inside the pod.
  • Shared ConfigMap for environment variables: The main app and all tootctl jobs need the same base env vars (DB host, Redis host, S3 config, etc.). I define them once in a ConfigMap and mount it via envFrom everywhere, so changes are a single edit.

Aside: I use Renovate to automatically open PRs when new Mastodon or Postgres images are released, so the instance stays current without manual checking. That's out of scope for this article, but worth looking into if you plan to run this long-term.

Database and cache

Postgres and Redis are deployed as separate controller blocks within the same Argo CD Application, not as external managed services. For a single-user or small instance, this is perfectly fine and keeps costs at zero.

controllers:
  mastodondb:
    type: deployment
    containers:
      postgres:
        image:
          repository: postgres
          tag: "18.6"
        env:
          POSTGRES_DB: <your-db-name>
          POSTGRES_USER: <your-db-user>
  mastodonredis:
    type: deployment
    containers:
      redis:
        image:
          repository: redis
          tag: "8.10.1"
        args:
          - --save
          - ""
          - --appendonly
          - "no"

Note: Redis runs without persistence or AOF. If it restarts, Sidekiq queues rebuild and caches warm back up. For a small instance, that's acceptable.

Secrets without committing them

All secrets live in an external secret store (anything supported by the External Secrets Operator) and are synced into the cluster automatically. The ExternalSecret resource declares exactly which remote keys map to which Kubernetes Secret keys:

  • DB_PASS
  • SECRET_KEY_BASE
  • OTP_SECRET
  • VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY
  • ACTIVE_RECORD_ENCRYPTION_*
  • SMTP_PASSWORD
  • S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY

This means the Git repo contains zero secrets, and rotation is as easy as updating the upstream secret store.

Media storage with Garage

Mastodon media can grow fast. Rather than storing it on cluster disks, I use Garage, a lightweight S3-compatible object store that runs elsewhere on my homelab. Mastodon is configured with standard S3 env vars:

S3_ENABLED: "true"
S3_BUCKET: mastodon-media
S3_REGION: garage
S3_ENDPOINT: http://<your-garage-node>:<port>
S3_HOSTNAME: s3.m6n.ca
S3_PROTOCOL: https

I also run a small nginx proxy inside the cluster to serve public media directly from Garage under a clean s3.m6n.ca domain, which keeps URLs pretty and offloads the Mastodon pod.

Db Backups cronjob

A daily CronJob runs pg_dump and uploads the result to a separate S3 bucket:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: mastodon-db-backup
  namespace: m6n
  annotations:
    argocd.argoproj.io/sync-wave: "1"
spec:
  schedule: "30 6 * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: backup
              image: postgres:18.6
              command:
                - /bin/sh
                - -c
                - |
                  set -e
                  DATE=$(date +%Y%m%d-%H%M%S)
                  BUCKET="mastodon-db-backup"
                  ENDPOINT="http://<your-s3-endpoint>"

                  echo "Installing AWS CLI"
                  apt-get update -qq && apt-get install -y -qq awscli > /dev/null 2>&1

                  echo "Starting backup at $DATE"
                  pg_dump -h m6n-mastodon-app-mastodondb -U <your-db-user> -d <your-db-name> -F c -f /tmp/backup.dump

                  echo "Uploading to S3"
                  aws s3 cp /tmp/backup.dump s3://$BUCKET/mastodon-db-$DATE.dump \
                    --endpoint-url $ENDPOINT \
                    --region garage

                  echo "Cleaning up old backups (older than 14 days)"
                  aws s3 ls s3://$BUCKET/ --endpoint-url $ENDPOINT --region garage | \
                    grep mastodon-db- | \
                    awk '{print $4}' | \
                    while read file; do
                      FILE_DATE=$(echo $file | sed 's/mastodon-db-\([0-9]*\)-.*/\1/')
                      FILE_EPOCH=$(date -d "$(echo $FILE_DATE | cut -c1-8)" +%s 2>/dev/null || echo 0)
                      CUTOFF=$(date -d "14 days ago" +%s)
                      if [ $FILE_EPOCH -lt $CUTOFF ]; then
                        echo "Deleting old backup: $file"
                        aws s3 rm s3://$BUCKET/$file --endpoint-url $ENDPOINT --region garage
                      fi
                    done

                  echo "Backup completed successfully"
              env:
                - name: PGUSER
                  value: <your-db-user>
                - name: PGPASSWORD
                  valueFrom:
                    secretKeyRef:
                      name: mastodon-credentials
                      key: DB_PASS
                - name: AWS_ACCESS_KEY_ID
                  valueFrom:
                    secretKeyRef:
                      name: mastodon-backup-credentials
                      key: S3_ACCESS_KEY_ID
                - name: AWS_SECRET_ACCESS_KEY
                  valueFrom:
                    secretKeyRef:
                      name: mastodon-backup-credentials
                      key: S3_SECRET_ACCESS_KEY
              resources:
                requests:
                  memory: 256Mi
                limits:
                  memory: 1Gi
          restartPolicy: OnFailure

It keeps 14 days of backups and prunes anything older automatically. The job uses the same Postgres image as the database to guarantee version compatibility.

Maintenance: keeping the instance lean

Mastodon accumulates A LOT of cruft. I run a weekly maintenance CronJob (Sunday 3am) that executes a handful of tootctl commands. The job mounts a ConfigMap with the script and runs from the Mastodon image itself, inheriting the same environment and secrets as the main app.

The CronJob:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: mastodon-weekly-maintenance
  namespace: m6n
  annotations:
    argocd.argoproj.io/sync-wave: "1"
spec:
  schedule: "0 3 * * 0"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      ttlSecondsAfterFinished: 1209600
      template:
        spec:
          restartPolicy: OnFailure
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            runAsGroup: 1000
            seccompProfile:
              type: RuntimeDefault
          containers:
            - name: maintenance
              image: "ghcr.io/linuxserver/mastodon:4.7.1"
              imagePullPolicy: Always
              workingDir: /app/www
              command:
                - /bin/bash
                - /scripts/maintenance.sh
              envFrom:
                - configMapRef:
                    name: mastodon-shared-env
                - secretRef:
                    name: mastodon-credentials
              resources:
                requests:
                  memory: 256Mi
                limits:
                  memory: 1Gi
              securityContext:
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                capabilities:
                  drop: ["ALL"]
              volumeMounts:
                - name: maintenance-script
                  mountPath: /scripts
                  readOnly: true
                - name: tmp
                  mountPath: /tmp
          volumes:
            - name: maintenance-script
              configMap:
                name: mastodon-maintenance-script
                defaultMode: 0555
            - name: tmp
              emptyDir: {}

And the ConfigMap it mounts:

apiVersion: v1
kind: ConfigMap
metadata:
  name: mastodon-maintenance-script
  namespace: m6n
  annotations:
    argocd.argoproj.io/sync-wave: "0"
data:
  maintenance.sh: |
    #!/bin/bash
    set -euo pipefail
    cd /app/www

    DRY_RUN="${DRY_RUN:-false}"

    maybe_dry_run=()
    if [ "$DRY_RUN" = "true" ]; then
      maybe_dry_run=(--dry-run)
    fi

    echo "Starting Mastodon weekly maintenance (DRY_RUN=$DRY_RUN)"

    echo "[1/6] tootctl media remove --days=7"
    bundle exec tootctl media remove --days=7 "${maybe_dry_run[@]}"

    echo "[2/6] tootctl media remove-orphans"
    bundle exec tootctl media remove-orphans "${maybe_dry_run[@]}"

    echo "[3/6] tootctl media remove --remove-headers --days=14"
    bundle exec tootctl media remove --remove-headers --days=14 "${maybe_dry_run[@]}"

    echo "[4/6] tootctl preview_cards remove --days=14"
    bundle exec tootctl preview_cards remove --days=14 "${maybe_dry_run[@]}"

    echo "[5/6] tootctl statuses remove --days=60"
    if [ "$DRY_RUN" = "true" ]; then
      echo "      -> skipping in dry-run mode"
    else
      bundle exec tootctl statuses remove --days=60
    fi

    echo "[6/6] tootctl accounts prune"
    bundle exec tootctl accounts prune "${maybe_dry_run[@]}"

    echo "Mastodon weekly maintenance complete"

Steps in order:

  1. Remove media older than 7 days
  2. Remove orphaned media records
  3. Remove profile headers older than 14 days
  4. Remove preview cards older than 14 days
  5. Remove statuses older than 60 days
  6. Prune unreachable accounts

There's also a monthly cull job that runs tootctl accounts cull, parses the output for unreachable domains, and purges them. This keeps the database size reasonable and Sidekiq queues snappy.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: mastodon-monthly-cull
  namespace: m6n
  annotations:
    argocd.argoproj.io/sync-wave: "1"
spec:
  schedule: "0 3 28 * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      ttlSecondsAfterFinished: 5184000
      template:
        spec:
          restartPolicy: OnFailure
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            runAsGroup: 1000
            seccompProfile:
              type: RuntimeDefault
          containers:
            - name: cull
              image: "ghcr.io/linuxserver/mastodon:4.7.0"
              imagePullPolicy: Always
              workingDir: /app/www
              command:
                - /bin/bash
                - -c
                - |
                  set -uo pipefail
                  cd /app/www

                  echo "Running accounts cull..."
                  bundle exec tootctl accounts cull 2>&1 | tee /tmp/cull.log

                  echo ""
                  echo "Extracting unreachable domains from cull output..."
                  sed -n '/The following domains were not available during the check:/,/^$/p' /tmp/cull.log \
                    | tail -n +2 \
                    | sed 's/\x1b\[[0-9;]*m//g; s/^[[:space:]]*//; s/[[:space:]]*$//' \
                    | grep -v '^$' \
                    > /tmp/unreachable-domains.txt

                  if [ ! -s /tmp/unreachable-domains.txt ]; then
                    echo "No unreachable domains to purge."
                    exit 0
                  fi

                  echo ""
                  echo "Purging unreachable domains:"
                  cat /tmp/unreachable-domains.txt

                  failures=()
                  while IFS= read -r domain; do
                    echo ""
                    echo "Purging domain: $domain"
                    if bundle exec tootctl domains purge "$domain"; then
                      echo "  OK"
                    else
                      echo "  FAILED: $domain"
                      failures+=("$domain")
                    fi
                  done < /tmp/unreachable-domains.txt

                  if [ ${#failures[@]} -gt 0 ]; then
                    echo ""
                    echo "The following domains could not be purged:"
                    printf '  %s\n' "${failures[@]}"
                    exit 1
                  fi

                  echo ""
                  echo "All unreachable domains purged."
              envFrom:
                - configMapRef:
                    name: mastodon-shared-env
                - secretRef:
                    name: mastodon-credentials
              resources:
                requests:
                  memory: 256Mi
                limits:
                  memory: 1Gi
              securityContext:
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                capabilities:
                  drop: ["ALL"]
              volumeMounts:
                - name: tmp
                  mountPath: /tmp
          volumes:
            - name: tmp
              emptyDir: {}

Feed rebuild on deploy

After every Argo CD sync, a PostSync hook job rebuilds user feeds with tootctl feeds build. This ensures timeline continuity if the database or Redis were interrupted during an update.

apiVersion: batch/v1
kind: Job
metadata:
  name: mastodon-feed-rebuild
  namespace: m6n
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  ttlSecondsAfterFinished: 3600
  template:
    spec:
      restartPolicy: OnFailure
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: feed-builder
          image: "ghcr.io/linuxserver/mastodon:4.7.1"
          imagePullPolicy: Always
          workingDir: /app/www
          command:
            - bundle
            - exec
            - tootctl
            - feeds
            - build
          envFrom:
            - configMapRef:
                name: mastodon-shared-env
            - secretRef:
                name: mastodon-credentials
          resources:
            limits:
              memory: 1Gi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]

Putting it together

The directory layout in Git is straightforward:

services/apps/m6n/mastodon/
├── m6n-namespace.yaml
├── m6n-secretstore.yaml
├── m6n-mastodon-external-secrets.yaml
├── m6n-mastodon-shared-env.yaml   # Shared env vars ConfigMap
├── m6n-mastodon-app.yaml          # The main Argo CD Application
├── m6n-mastodon-nginx-config.yaml # nginx site config
├── m6n-mastodon-route.yaml        # Envoy Gateway HTTPRoute
├── m6n-garage-web-proxy.yaml      # S3 web proxy app
├── m6n-garage-route.yaml          # S3 public route
├── m6n-mastodon-backup-cronjob.yaml
├── m6n-mastodon-backup-external-secrets.yaml
├── m6n-mastodon-maintenance-cronjob.yaml
├── m6n-mastodon-maintenance-configmap.yaml
├── m6n-mastodon-cull-cronjob.yaml
└── mastodon-feed-rebuild-job.yaml

Everything is versioned, peer-reviewable, and reproducible.

Things I'd do differently at scale

This setup is intentionally simple. If I ever outgrew it, the first changes would be:

  • Separate Sidekiq and streaming into their own controller blocks or deployments so they can scale independently.
  • Managed Postgres (or at least a proper StatefulSet with backups) instead of a single Deployment.
  • Elasticsearch for full-text search, since ES_ENABLED: false means search is limited.
  • More than one Mastodon replica behind sticky sessions, though that requires careful handling of WebSocket connections.

For a personal or small-community instance, though, this hits a sweet spot between complexity and reliability.

Resources


If you're thinking about self-hosting Mastodon, my advice is: start simple, automate your backups, and keep your secrets out of Git. Everything else is an optimization you can layer on later. Feel free to send me a mention on Mastodon if you have any questions or comments!