Social media is how many people experience the Internet today. There’s a good chance you found this post through one of them. But the way we connect online doesn’t have to be dictated by a handful of platforms. Beyond the single-app, single-site giants, there’s the Fediverse: a constellation of independent social networks that talk to each other.
Think of it like email: no single company in charge, no central service to rule them all. It’s powered by a mix of software for text, photos, podcasts, events, and more.
Mastodon is one of the most popular of these–a “microblogging” platform like X (née Twitter), but open-source and federated. You can join one of thousands of servers run by others… or, if you’re me, you host your own on a tiny Mac mini sitting on top of a file cabinet.
But why?
I’d been happily tooting away on a couple of well-run Mastodon servers for years. But I kept running into little things I wanted to tweak: themes, moderation settings, even the domain of my handle. None of them were deal-breakers, but they added up. Eventually, I realized that the only way to get exactly what I wanted was to set it up myself.
The hardware
When I say tiny, I mean tiny: a low-spec 2023 M2 Mac mini, 8 GB memory, 512 GB solid-state drive. My wife and I had set it up in our home office to drive a TV displaying our shared schedule. I had also installed a Calibre server for our e‑book library.
So the mini isn’t even close to breaking a sweat yet. And I was encouraged to read that others were successfully running single-user Mastodon servers on a Raspberry Pi.
The mini was more than capable. The next step was figuring out how to run Mastodon without becoming a maintenance headache.
The containers
Mastodon’s official installation instructions involve setting up a variety of services on a Linux VPS (virtual private server). But there’s an easier, literally more self-contained way: Docker containers, orchestrated through Docker Compose and running via Docker Desktop.
Mastodon’s source code repository even includes a starter docker-compose file describing:
- a PostgreSQL database
- a Redis cache
- the Mastodon web application
- its ancillary streaming service and Sidekiq background event queue
Everything is containerized. Still, there must be a way to keep the host safe from the open Internet.
Avoiding overexposure
Directly exposing the Mac mini to the full malice of the Internet filled me with dread. And besides, my home connection lacks a guaranteed fixed address to which I’d attach a domain name. My solution? Cloudflare Tunnel, a service run by my domain registrar and name service.
All I need to do is add another Docker container service. Cloudflare manages the web traffic to and from Mastodon. The other services and the host Mac then stay safe from harm.
With the tunnel in place, I can focus on keeping the setup lean and easy to update.
Aiming for maintainability
Despite all of these moving parts, I still aim to keep the Mac philosophy of simplicity. To change only what I need, I use git to clone Mastodon’s GitHub repository. Then, I check out the latest release tag. Finally, I’ve got this docker-compose.override.yml file with just my modifications for the various container services:
docker-compose.override.yml
x-mastodon-local: &mastodon-local
secrets:
- postgres_password
- smtp_password
- redis_password
- secret_key_base
- vapid_private
- ar_enc_primary
- ar_enc_deterministic
- ar_enc_salt
volumes:
- ./local/scripts/with-secrets.sh:/with-secrets.sh:ro
services:
# not in upstream
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
secrets:
- cloudflared_tunnel_token
environment:
TUNNEL_TOKEN_FILE: /run/secrets/cloudflared_tunnel_token
command: tunnel --no-autoupdate run
depends_on:
- web
networks:
- external_network
# overriding upstream
db:
restart: unless-stopped
secrets:
- postgres_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
env_file: .env.db.local
healthcheck:
test:
- CMD-SHELL
- >
pg_isready --dbname=$$POSTGRES_DB --username=$$POSTGRES_USER
&& psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' >/dev/null
interval: 30s
timeout: 5s
retries: 5
# overriding upstream
redis:
restart: unless-stopped
# overriding upstream
web:
<<: *mastodon-local
command: ["/with-secrets.sh", "bundle", "exec", "puma", "-C", "config/puma.rb"]
restart: unless-stopped
ports: [] # doesn't actually override, just merges :-(
expose:
- 3000
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
es:
condition: service_healthy
# overriding upstream
streaming:
<<: *mastodon-local
command: ["/with-secrets.sh", "node", "./streaming/index.js"]
restart: unless-stopped
networks:
- internal_network
ports: [] # doesn't actually override, just merges :-(
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
# overriding upstream
sidekiq:
<<: *mastodon-local
command: /with-secrets.sh bundle exec sidekiq \
-q default \
-q ingress \
-q mailers \
-q pull \
-q push \
-q scheduler \
-q search \
-q indexing_scheduler
restart: unless-stopped
healthcheck:
test: ["CMD", "pgrep", "-f", "sidekiq"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- internal_network
- external_network
# commented out upstream
es:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.4
restart: unless-stopped
env_file: .env.es.local
networks:
- internal_network
healthcheck:
test:
- CMD-SHELL
- curl --silent --fail localhost:9200/_cluster/health || exit 1
volumes:
- ./elasticsearch:/usr/share/elasticsearch/data
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
secrets:
cloudflared_tunnel_token:
file: ./local/secrets/cloudflared_tunnel_token.txt
postgres_password:
file: ./local/secrets/postgres_password.txt
smtp_password:
file: ./local/secrets/smtp_password.txt
redis_password:
file: ./local/secrets/redis_password.txt
secret_key_base:
file: ./local/secrets/mastodon/secret_key_base.txt
vapid_private:
file: ./local/secrets/mastodon/vapid_private.txt
ar_enc_primary:
file: ./local/secrets/mastodon/activerecord/encryption_primary.txt
ar_enc_deterministic:
file: ./local/secrets/mastodon/activerecord/encryption_deterministic.txt
ar_enc_salt:
file: ./local/secrets/mastodon/activerecord/encryption_salt.txt
Full code listings are expandable on the website; email readers may need to click through to view them.
Let’s start from the bottom secrets section.
Secrets management
Rather than Mastodon’s typical approach of exposing passwords, tokens, and other sensitive information as environment variables, I use a more secure approach. I mount each one as a separate text file. Then relevant services can read these files directly or pass them as variables on startup.
To make sure I don’t accidentally commit private information to git, /local/secrets/ is in my repository’s .git/info/exclude file.
Shared configuration with a YAML extension
Back to the top, and I have a x-mastodon-local YAML extension for shared configuration among several of Mastodon’s services:
x-mastodon-local: &mastodon-local
secrets:
- postgres_password
- smtp_password
- redis_password
- secret_key_base
- vapid_private
- ar_enc_primary
- ar_enc_deterministic
- ar_enc_salt
volumes:
- ./local/scripts/with-secrets.sh:/with-secrets.sh:ro
This lists most of the aforementioned secrets, and mounts a wrapper script to set them as environment variables.
The wrapper script
Speaking of which:
with-secrets.sh
#!/bin/sh
set -eu
# Map of secret file name to environment variable name
for secret in \
"postgres_password:DB_PASS" \
"smtp_password:SMTP_PASSWORD" \
"redis_password:REDIS_PASSWORD" \
"secret_key_base:SECRET_KEY_BASE" \
"vapid_private:VAPID_PRIVATE_KEY" \
"ar_enc_primary:ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY" \
"ar_enc_deterministic:ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY" \
"ar_enc_salt:ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"
do
# Split the pair into file name and env var name
# ${var%%:*} -- strip everything from the first ":" onward
# (keeps left side)
name="${secret%%:*}"
# ${var##*:} -- strip everything up to and including the last ":"
# (keeps right side)
var="${secret##*:}"
file="/run/secrets/$name"
# If the secret file exists, read its contents into the env var
if [ -f "$file" ]
then
eval "export $var=\"$(cat "$file")\""
fi
done
# Hand off to the original command
exec "$@"
Full code listings are expandable on the website; email readers may need to click through to view them.
Since Mastodon wants everything, secrets included, as environment variables, this script:
- loops through a map of their corresponding secrets files
- exports each of their contents to the environment
- and then runs whatever command was passed along for the ride.
Customized containerized services
Remember that docker-compose.override.yml gets merged with the upstream docker-compose.yml file, so I only need to write necessary changes and additions. Let’s take them one at a time.
Cloudflare Tunnel
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
secrets:
- cloudflared_tunnel_token
environment:
TUNNEL_TOKEN_FILE: /run/secrets/cloudflared_tunnel_token
command: tunnel --no-autoupdate run
depends_on:
- web
networks:
- external_network
This service is completely new, with nothing to override. I specify everything from the Docker Hub-based image to the startup command. I also specify the external network used to talk to Cloudflare’s servers.
Of special note is the TUNNEL_TOKEN_FILE environment variable. This feature was added a mere six months ago. It enables loading the Cloudflare-provided authentication token directly from my mounted file in /run/secrets. This avoids stuffing its contents into an environment variable itself. I also add a depends_on item for the Mastodon web service. This ensures that Mastodon is running before I open the tunnel.
PostgreSQL database
db:
restart: unless-stopped
secrets:
- postgres_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
env_file: .env.db.local
healthcheck:
test:
- CMD-SHELL
- >
pg_isready --dbname=$$POSTGRES_DB --username=$$POSTGRES_USER
&& psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' >/dev/null
interval: 30s
timeout: 5s
retries: 5
This overrides several settings in the upstream docker-compose.yml file, most significantly in the secrets department, which follows the same pattern as the above cloudflared service.
And since the upstream git repository ignores files matching .env*.local, I set POSTGRES_DB and POSTGRES_USER environment variables in an .env.db.local file and harden the healthcheck.test to both check that PostgreSQL is accepting connections and that a simple query is successful. That check is run every thirty seconds and retried up to five times if unsuccessful. It times out after a mere five seconds, more than enough time because everything’s running on the same host.
A brief stop with Redis
redis:
restart: unless-stopped
The only change to upstream’s Redis configuration is restarting the service if it wasn’t manually stopped. (Every other service in my override file also uses this.) Then I can bring any of them down for maintenance or troubleshooting, and not worry about docker compose over-enthusiastically starting them up again.
The Mastodon services
The Mastodon container image itself runs two different services, with a third service next door:
web: UI and APIsidekiq: background jobs like federation and media processingstreaming: real-time updates–timelines, notifications–so they arrive instantly without page reloads
docker-compose.override.yml (Mastodon services excerpt)
web:
<<: *mastodon-local
command: ["/with-secrets.sh", "bundle", "exec", "puma", "-C", "config/puma.rb"]
restart: unless-stopped
expose:
- 3000
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
streaming:
<<: *mastodon-local
command: ["/with-secrets.sh", "node", "./streaming/index.js"]
restart: unless-stopped
networks:
- internal_network
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
sidekiq:
<<: *mastodon-local
command: /with-secrets.sh bundle exec sidekiq
restart: unless-stopped
healthcheck:
test: ["CMD", "pgrep", "-f", "sidekiq"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- internal_network
- external_network
Full code listings are expandable on the website; email readers may need to click through to view them.
Each of these services brings in the mastodon-local YAML extension defined earlier. This approach avoids repeating the same secrets and volumes each time. They also repeat upstream’s command entries, but wrapped in the with-secrets.sh script described above.
Each service also has a depends_on stanza that relies on the PostgreSQL and Redis service reporting good health. The rest is just networking tweaks. limiting external exposure to the web service only.
Again, the point here is maintainability and only overriding what’s not already covered by upstream’s docker-compose.yml file.
Bringing it all together
The containers are humming along. Secrets are tucked safely away. Cloudflare quietly handles the outside world. My little Mac mini now runs a fully-fledged Mastodon instance, still without breaking a sweat. It’s not just a proof-of-concept–it’s my daily driver for posting, following, and exploring the Fediverse.
Performance has been pleasantly uneventful: CPU and memory usage stay low, even during busy federated timelines. The tunnel has been rock-solid, and the override-only approach means I can pull upstream updates without dreading a merge marathon.
Lessons learned
- Secrets-as-files keep sensitive data out of the environment and out of version control–worth the extra setup.
- Docker override files are a sanity-saver; upstream changes flow in without trampling my tweaks.
- Health checks aren’t just for show–they’ve already caught a misbehaving Sidekiq service before it caused downtime.
- Cloudflare Tunnel removes the need for a static IP and keeps the host off the public Internet entirely.
If you’re thinking of trying this
Start small. You don’t need a rack of servers–a modest machine and a bit of container discipline can get you surprisingly far. Keep your changes minimal, document them as you go, and let upstream do the heavy lifting.
Running Mastodon this way has been a reminder that self-hosting doesn’t have to mean endless tinkering. With the right boundaries–both in network exposure and in configuration scope–it can be calm and predictable. It offers the satisfaction of knowing it’s entirely yours.
I’ve since enabled ElasticSearch for full-text search by copying over upstream’s commented-out es example service to my docker-compose.override.yml and lightly configuring it. It was a fairly simple addition that hasn’t affected performance on the Mac mini.
My next steps will involve lightweight monitoring and backup strategies. These steps will guarantee this little server can keep quietly doing its job for years to come.



