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,
Recreatestrategy: 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
tootctljobs need the same base env vars (DB host, Redis host, S3 config, etc.). I define them once in a ConfigMap and mount it viaenvFromeverywhere, 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_PASSSECRET_KEY_BASEOTP_SECRETVAPID_PRIVATE_KEY/VAPID_PUBLIC_KEYACTIVE_RECORD_ENCRYPTION_*SMTP_PASSWORDS3_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:
- Remove media older than 7 days
- Remove orphaned media records
- Remove profile headers older than 14 days
- Remove preview cards older than 14 days
- Remove statuses older than 60 days
- 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: falsemeans 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¶
- Mastodon official docs
- linuxserver/docker-mastodon
- bjw-s app-template chart
- Garage object store
- Argo CD
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!