A few days ago, Adam Havelka posted something on the Fediverse that made me stop mid-scroll:
“Mastodon updated from v4.5.9 to v4.6.1 — nothing better than having mastodon in kubernetes, just update one row in git, wait for argo magic... come back with coffee and latest instance is there”
Reader, I did not come back to coffee. I came back to rbenv, bundler, asset precompilation, a stalled Sidekiq, and a nagging feeling that I'd done something irreversible to production.
That was last time. This time -- 26 June 2026 -- I migrated lighthouse.co.im from a bare-metal source install to Docker Compose. The upgrade to v4.6.2 that's already waiting in the admin panel will be a tag bump and a pull. I'm told that's how it's supposed to work.
Not out of principle. That's the honest answer.
The original stack was Nginx and Docker, which is perfectly reasonable. What wasn't reasonable was Certbot. Specifically, the interaction between Certbot's renewal hooks, Nginx's reload behaviour, and the cert path assumptions baked into the Mastodon Docker image. Every 90 days was an adventure. Sometimes the cert renewed cleanly. Sometimes Nginx kept serving the old cert. Sometimes the hook fired but the reload didn't. Sometimes all three happened simultaneously and in the wrong order.
I switched to Caddy to escape cert lifecycle hell. Caddy handles TLS automatically -- it just works -- and I never had to think about it again. But here's the architectural consequence nobody warns you about: the official Mastodon installation guide is bare-metal native. It assumes you're installing Ruby with rbenv, managing gems with Bundler, running rails assets:precompile on the host. When you're already running Caddy on bare metal as your reverse proxy, you sort of... keep going down that road. The Docker setup is an alternative path, not the default.
So the architecture solidified not through deliberate choice but through accumulated decisions, each of which made sense at the time.
The reason I'd avoided going back to Docker was the original pain -- TLS cert management with Nginx. But Caddy solves that regardless of what's behind it. Caddy doesn't care if the upstream is a bare-metal Puma process or a containerised one. It just repoints. The TLS story is unchanged.
Once I saw that clearly, the calculus shifted. The upgrade path on bare metal is: check Ruby compatibility, update rbenv, bundle install, run migrations, precompile assets, restart services, hope. The upgrade path on Docker is: change a version number, pull, recreate. Adam's post made me look at my own setup and realise I was maintaining complexity I'd never actually chosen.
The plan had three phases. Lab validation on my M4 (grimoire), S3 validation against a throwaway Infomaniak bucket, then production on the Netcup box. The lab and S3 phases went smoothly enough -- a few friction points, nothing dramatic. Production is where it got interesting.
The Netcup box already had Docker 29.6.0 and Compose v5.2.0 installed from previous work, so no upgrade faff. I took a pg_dump -Fc backup -- 3.1GB -- verified it, then started building the parallel stack on port 3002 (port 3001 was already occupied by a PayPal webhook container, which is exactly the sort of thing you discover mid-migration).
The database restore went in with 224 warnings about role "postgres" does not exist. These look alarming. They're harmless -- the PostgreSQL container's superuser in this stack is mastodon, not postgres, so any dump-time references to the postgres role just get skipped. Everything that matters restores correctly.
Then I hit the actual interesting problem.
The friction that earned its place in this article
Migration 20260318144837 -- AddInviteApprovalBypassPermission -- hard-failed. The migration assumes the user_roles.permissions column is an integer, so it can do bitwise arithmetic on it. On a production instance that's been running for a while, that column is jsonb. The migration doesn't know this. PostgreSQL refuses to do bitwise OR on a jsonb column. Rails bails out.
The fix was manual: apply the update with an explicit cast, then insert the migration version into schema_migrations directly so Rails knows it's been run. Not complicated, but not documented anywhere obvious. Worth knowing if you're migrating a production instance rather than starting fresh.
With migrations done and all five containers healthy, the cutover itself was almost anticlimactic. One sed -i in the Caddy config to repoint the upstream from 127.0.0.1:3000 to 127.0.0.1:3002, a Caddy reload, done.
First thing I checked after logging in: my avatar was there. That's S3 working end-to-end -- media on Infomaniak Swift, serving correctly through the containerised stack. tootctl media usage confirmed 85.5GB of attachments all accounted for. Admin panel clean. Federation catching up through Sidekiq.
For anyone curious: Mastodon v4.6.0 in Docker Compose on a Netcup RS 2000 G12. Caddy still on bare metal as the reverse proxy -- that never moved, and that's the point. PostgreSQL 14-alpine pinned deliberately (major version upgrades need a dump/restore, not a tag bump; the Docker volume is named postgres14 as a standing reminder to future-me). Media on Infomaniak S3-compatible Swift, data in Switzerland. Forgejo for git.
v4.6.2 is already showing as available in the admin panel. That upgrade will be three commands. I might have a coffee first.
If you're running Mastodon on bare metal and the upgrade path is causing you grief, the Docker route is worth reconsidering -- particularly if you're already running Caddy. The TLS argument against it dissolves completely in that architecture. The migration takes an afternoon and one manual database fix that I've now documented so you don't have to rediscover it yourself.
The instance is at lighthouse.co.im. Still small, still quiet, still sovereign.
The Sovereign Auditor covers digital sovereignty, cybersecurity governance, and data protection policy—with particular focus on Isle of Man jurisdiction and Crown Dependency issues.
Payments via PayPal. Credentials delivered by email. No Substack. No Stripe. No middlemen.