Screenshot of VS Code with Mastodon being upgraded via SSH by Claude AI.

Upgrading my Mastodon server was rough, but AI helped a lot.

I made my own social media network federated via ActivityPub on the Fediverse running Mastodon 4.1 a few years ago. I did it with a script that installed everything on an Oracle Cloud VPS.  I did not use the Docker method because I thought that might be extra RAM that could be used elsewhere. Then I became lazy about learning how to upgrade Mastodon on an installation built from source.  Reading the release notes and documentation for each Mastodon upgrade involved lots of special tweaks and database migrations that sounded pretty dangerous. I was worried about breaking it completely, so I kept putting it off. I thought about paying a friend to help too. Then I decided to give Claude Code SSH access to the server and let the AI evaluate what needed to be done. 

The Opus 5 AI model looked at my server, scanned to see how it was configured, and developed the below runbook as a plan of attack. After reading through it and deciding that it all sounded good, I decided to just let Claud Opus proceed. I was using the Claude Code plugin with Visual Studio Code and it all took quite a long time.  Several times it seemed like Claude had stalled or frozen as there was no way for it to display progress on some of the procedures that it was running. So I had to “resume” the runbook process several times. This was totally fine as we had built out the runbook plan as an HTML web page ahead of time. 

 

Lein Social Upgrade Runbook

A three-year-old source install is now Mastodon 4.7.1 in containers on Ubuntu 24.04 LTS. This revision is the record of how, and the reference for running it.

4.1.2 on 22.04 4.7.1 on 24.04 complete · 16 min total downtime
Mastodon4.7.1Docker Compose, 5 services
OS24.04 LTSkernel 6.17.0-1020-oracle
Host1 vCPU5.8 GB RAM, aarch64, 4 GB swap
Disk16 GB free30 of 45 GB used
Database1.7 GBpostgres:15-alpine
Accounts4 local34,475 known

00 Status

P0 Swap added, remote media cache pruned, custom emoji purgeddone 5 Sep
P1 Full backup taken and restore-verifieddone 5 Sep
P2 Docker installed, compose stack built, media stageddone 5 Sep
P3 Restored into containers, six-hop upgrade chain walkeddone 6 Sep
P4 nginx repointed, cutover to containersdone 6 Sep
P5 Verified: version, data, assets, federation, maildone 6 Sep
+Outbound mail moved to OCI Email Deliverydone 7 Sep
+Old install removed; maintenance cron fixed and provendone 7 Sep
+Ubuntu 22.04 → 24.04 LTS release upgradedone 7 Sep
!Recurring off-box backup — still manual, one-off onlyopen
!Object storage for media — the durable fix for disk growthrecommended
Total service downtime for the entire migration: ~16 minutes

4 m 05 s to take the verified backup · 10 m 50 s for the cutover to containers · ~90 s for the 24.04 reboot.

The six-hop upgrade chain and the whole OS release upgrade ran with the site live. That is the payoff from containerising first: the host was rewritten underneath a running Mastodon.

01 What runs now

Everything Mastodon lives under /opt/mastodon. nginx and certbot stay on the host and were never rebuilt — compose publishes on the same 127.0.0.1:3000 and :4000 the original upstreams already pointed at.

Host

nginxTLS, proxy, static
certbot.timerrenews to 7 Nov
docker + containerd29.8.0 (noble)
ufwdeny in; 22/80/443
cronnightly prune 03:00

Containers — /opt/mastodon

webmastodon:v4.7.1
sidekiqmastodon:v4.7.1 · -c 8
streamingmastodon-streaming:v4.7.1
dbpostgres:15-alpine
redisredis:7-alpine
PathWhat it is
/opt/mastodon/docker-compose.ymlThe stack. Image tags come from MASTODON_VERSION in .env.
/opt/mastodon/.envMASTODON_VERSION and POSTGRES_PASSWORD. Mode 600, root.
/opt/mastodon/.env.productionMastodon config and all secrets. Mode 600, root.
/opt/mastodon/postgres15/Database volume.
/opt/mastodon/public/system/Media. Owned 991:991 (the image’s uid).
/opt/mastodon/redis/Redis volume.
/usr/local/bin/mastodon-maintenance.shCache maintenance, run by cron.
/home/ubuntu/mastodon-backup-2026-09-05/The verified backup. Also copied off-box.
/root/pre-noble-config/Config snapshot taken before the OS upgrade.
Three things that will surprise you later

Puma takes 60–105 s to preload. A health check straight after any restart returns 502 and looks broken. Wait for :3000/health before concluding anything. The compose healthcheck has start_period: 180s so it no longer reports false “unhealthy”.

nginx serves /system/ from disk and proxies everything else. All eight static blocks use try_files $uri @proxy. If they ever revert to =404 you get a site that loads with no CSS or JS.

Run long jobs under systemd-run, never a login shell. This box takes unattended kernel reboots and SSH sessions drop. Every long operation in this migration was wrapped: sudo systemd-run --unit=NAME --collect --service-type=exec bash -c '…'.

02 Operating it

Upgrading Mastodon

This is the whole point of the exercise. A release is now a one-line edit.

cd /opt/mastodon
sudo sed -i 's/^MASTODON_VERSION=.*/MASTODON_VERSION=v4.8.0/' .env
sudo docker compose pull
sudo docker compose run --rm web bundle exec rails db:migrate
sudo docker compose up -d
# then wait ~90 s and check
curl -s https://social.lein.us/api/v1/instance | grep -o '"version":"[^"]*"'

Rolling back is the same edit in reverse, provided the new version ran no migrations. Once migrations have applied, going back means restoring a dump — so take one first for a major release.

Read the release notes for gates

Minimum PostgreSQL, Redis, Ruby and Node versions rise over time, and a release occasionally demands a new secret — 4.3 required three. The image carries Ruby, Node, libvips and ffmpeg, but PostgreSQL and Redis are yours to keep current: they are pinned in docker-compose.yml at postgres:15-alpine and redis:7-alpine.

Backups

# database — takes ~2 min, no downtime needed (pg_dump is a consistent snapshot)
cd /opt/mastodon
sudo docker compose exec -T db pg_dump -U mastodon -Fc mastodon_production \
  > ~/mastodon_$(date +%F).dump

# local media — small; the remote cache is regenerable, skip it
sudo tar czf ~/local-media_$(date +%F).tar.gz \
  -C /opt/mastodon/public/system accounts media_attachments site_uploads

# the secrets, without which the dump is useless
sudo cp /opt/mastodon/.env.production ~/env.production_$(date +%F)
A dump you have not restored is not a backup

Prove it, into a scratch database, and count something you know:

sudo docker compose exec -T db createdb -U mastodon restoretest
sudo docker compose exec -T db pg_restore -U mastodon -d restoretest < ~/mastodon_DATE.dump
sudo docker compose exec -T db psql -U mastodon -d restoretest -At \
  -c "SELECT count(*) FROM statuses WHERE account_id=110248000680880933;"
sudo docker compose exec -T db dropdb -U mastodon restoretest

And get it off this machine. A backup on the disk it protects only survives the failures that were never going to matter.

Cache maintenance

Runs itself. /usr/local/bin/mastodon-maintenance.sh, logging to /var/log/mastodon-maintenance.log (rotated monthly), both entries flock-guarded so they cannot overlap.

ScheduleDoesWhy that cadence
0 3 * * *media remove --days 2
preview_cards remove --days 7
This instance ingests ~5.6 GB/day of remote media. At 2 days the cache settles near ~11 GB; at 3 days it was ~17 GB.
0 4 1 * *accounts prune
statuses remove --days 30
media remove-orphans
Orphan scan is a full-disk walk (~10 min) that has found 0–1 files in practice, so it is monthly rather than nightly.
Two things deliberately not scheduled

emoji purge --remote-only. Measured 5 Sep: purging all 79,705 remote emoji reclaimed 1.82 GB — and it was fully back within 36 hours as they re-federated. It buys a day or two of space for the cost of re-downloading everything.

media remove --remove-headers --include-follows --days 0, which the original purge-media.sh ran. It deletes avatars and headers for accounts you actively follow, which Mastodon then immediately re-fetches. It spends bandwidth to reclaim space it gives straight back.

Restoring

cd /opt/mastodon
sudo docker compose stop web sidekiq streaming
sudo docker compose exec -T db dropdb -U mastodon mastodon_production
sudo docker compose exec -T db createdb -U mastodon mastodon_production
sudo docker compose exec -T db pg_restore -U mastodon -d mastodon_production \
  --no-owner --role=mastodon < /path/to/mastodon_DATE.dump
sudo tar xzf /path/to/local-media_DATE.tar.gz -C /opt/mastodon/public/system
sudo chown -R 991:991 /opt/mastodon/public/system
sudo docker compose run --rm web bundle exec rails db:migrate
sudo docker compose run --rm web bin/tootctl feeds build
sudo docker compose up -d
Pass dumps on stdin, not as a path

/home/ubuntu is mode 750, so a postgres user cannot traverse it and pg_restore /path/to.dump fails with a bare “Permission denied” that looks like a corrupt file. The < redirect runs as your shell and sidesteps it. This cost real time to diagnose.

Mail

Outbound goes through OCI Email Delivery, smtp.email.us-ashburn-1.oci.oraclecloud.com:587, STARTTLS, verified end to end on 7 Sep.

Port 587 is STARTTLS, not implicit TLS

SMTP_SSL and SMTP_TLS must be false with SMTP_ENABLE_STARTTLS=always. The previous provider used 465 with SMTP_SSL=true; carrying that over to 587 silently breaks all mail. Also note OCI only accepts mail from an approved sender — authentication succeeding proves nothing about whether it will deliver.

03 What was done

Chronological, with measured results. Times are UTC.

WhenStepResult
5 Sep 16:56InspectionFound 4.1.2 on main@9d75b03ba — a dev snapshot, not a release tag. No backups existed at all.
5 Sep 18:24Swap + prune4 GB swap, swappiness=10. Accounts 55,482 → 31,294; disk 33 → 24 GB used.
5 Sep 21:57Backup446 MB dump + 18 MB media. 4 m 05 s downtime.
5 Sep 22:07Restore-verifiedpg_restore rc=0, 904 TOC entries. 941/30/795/360 and 4 users exact; post text verbatim.
5 Sep 23:08Emoji purge79,705 → 0 in 22 m 34 s. Zero local emoji existed to lose.
5 Sep 23:19Docker installed29.8.0 + Compose v5.5.1, arm64. Container networking verified against ufw.
6 Sep 00:47Restore into containers4 m 43 s. Counts matched exactly; schema at 4.1-era 20230215074423.
6 Sep 00:48–01:05Six-hop chain17 minutes, site live throughout. Every hop gated on 941 statuses / 4 users.
6 Sep 01:21Cutover10 m 50 s downtime. Web up 65 s after container start; public 200 immediately after.
7 Sep 13:43Mail moved to OCIAccepted by relay, delivered.
7 Sep 14:55Old install removedHost PG, Redis, Node 16, ffmpeg, ImageMagick, /home/mastodon, 3 units, the mastodon account. 7 health gates, no drop.
7 Sep 15:12Ubuntu 24.04rc=0 in 12 minutes, Mastodon live throughout.
7 Sep 15:20RebootKernel 6.17.0-1020-oracle. Site answered 200 10 s after boot. 0 failed units.

The six-hop chain

4.1 → 4.7 is not one jump. Each release carries migrations, and several carry hard gates. Under Docker each hop is a tag change, which is what made it 17 minutes instead of an evening.

HopSchema afterGate it carried
v4.2.2920230907150100Ruby ≥3.0, Node ≥16, PG ≥10
v4.3.2320241007071624Three new encryption secrets. Image splits web/streaming. yarn 1 → 4.
v4.4.2420250627132728Redis ≥6.2, PG ≥13. Redis namespaces dropped.
v4.5.1720251023210145Redis ≥7.0, PG ≥14
v4.6.720260611150940ImageMagick removed, libvips required, ffmpeg ≥5.1
v4.7.120260812154114Long migrations: account uri uniqueness, ActivityPub identity rework
The 4.3 encryption secrets are permanent

ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY, _DETERMINISTIC_KEY and _KEY_DERIVATION_SALT live in .env.production and are in the backup. Lose them and everything encrypted with them is unrecoverable, including users’ stored 2FA secrets. They were generated and backed up before the 4.3 migrations ran, deliberately in that order.

Why containerising was the unblocker, not a preference

Mastodon 4.6 dropped ImageMagick and made libvips mandatory, and raised ffmpeg to ≥5.1. Mastodon 4.5 raised Redis to ≥7.0. Ubuntu 22.04 shipped libvips 8.12.1, ffmpeg 4.4.2 and Redis 6.0.16 — none of them qualified, and no newer versions existed in its repositories. Mastodon’s own Dockerfile resolves this by compiling libvips 8.18.5 and ffmpeg 9.0.1 from source inside the image.

A native upgrade meant building Ruby 4.0.6, Node 24, libvips and ffmpeg from source on a single ARM core — and repeating it at every release. The prebuilt image removed all of it. It also meant the 24.04 upgrade two days later was an ordinary do-release-upgrade that never took Mastodon down.

04 What surprised us

Kept because the reasoning matters more than the conclusions, and because several of these were wrong the first time.

BelievedActually
public/system held ~24 GB, largely orphaned filesA full remove-orphans scan found 3 files, 243 KB. It was all live remote cache. There was never an orphan problem.
Age-based pruning would reclaim the disk--days 7 and even --days 2 removed zero bytes. Nothing is stale — the cache is entirely recent, because inflow is ~5.6 GB/day. The disk is simply smaller than the firehose.
Emoji purging is worth scheduling1.82 GB reclaimed, fully regrown in 36 hours. Dropped from the schedule.
The emoji purge would take ~6 hours22 minutes. The first estimate extrapolated from 50 seconds that were mostly Rails booting.
Gate the migration on >25 GB freeThat number was sized against the mistaken 24 GB estimate. Phase 2/3 needed ~6 GB. The gate was wrong, not the outcome.
The account was adamAdam, capitalised. Mastodon matches handles case-insensitively, so both resolve — but SQL does not.
The 22.04 → 24.04 upgrade would take 45–90 min12 minutes, and it never interrupted Mastodon.
The release upgrade silently removed ufw

do-release-upgrade uninstalled ufw and left INPUT policy: ACCEPT where it had been DROP. Exposure did not change in practice — only 22/80/443 listen externally — but default-deny was gone, and nothing announced it. Reinstalled and re-enabled from the preserved /etc/ufw rules. Check your firewall after any release upgrade. It also disabled the Docker apt repo (renamed to docker.list.distUpgrade), which would have quietly stopped Docker security updates.

05 Adam’s account, and the truth about restore

The account is Adam — capitalised, id 110248000680880933, admin. Mastodon resolves handles case-insensitively, so [email protected] is this account; there is no separate lowercase one. It came through the migration at 941 statuses, 30 attachments (7.01 MB), 795 following, 360 followers, and has since posted — 946 at the time of writing, which is the clearest evidence the new stack is genuinely in use.

Correct this assumption before you ever rely on it

Mastodon has no import for the account archive. The .tar.gz from “Request your archive” is a portability and readability format — outbox.json, actor.json, media. No version, 4.7.1 included, can load it back. There is also no tootctl command that restores one account.

So the only thing that brings back Adam’s posts is the full database restore. Per-account exports are a real but partial second net.

ArtefactRestorable?
mastodon_2026-09-05.dumpYes — the real path Everything: all accounts, posts, follows, tokens.
local-media_2026-09-05.tar.gzYes Untar into public/system, then chown 991:991.
following / lists / blocks / mutes / bookmarks .csvYes Via Import in the web UI.
archive .tar.gzNo import exists Readable reference only.
account-adam/*.csvManual Reconciliation aid, not a restore path.
FollowersNot exportable Held on remote servers; they re-federate over time.
Do not count CSV records with wc -l

Post text contains newlines, so one record often spans several physical lines — statuses.csv reads as 1,061 lines for 941 posts. Use a CSV reader, or count in SQL.

06 Still open

1 · Backups are a one-off, not a habit

One verified backup exists and has been copied off-box. Nothing recreates it tomorrow. The instance has been running two days past that snapshot. This is now the largest single risk, and it is one cron line plus somewhere to put the file — see section 02.

2 · Media growth is managed, not solved

Nightly pruning at --days 2 should hold the cache near ~11 GB against ~5.6 GB/day of inflow. It is a treadmill: whatever cadence you pick, you run it forever, and a busier feed moves the equilibrium.

The durable fix is S3-compatible object storage for media, which Mastodon supports natively and which OCI provides. That removes the disk ceiling instead of managing it. The first genuinely productive nightly run is the night of 7–8 Sep; watch free space then to confirm it stabilises rather than climbs.

3 · Smaller items

Adam’s own web-UI exports (Settings → Import and export) were never taken — the one step needing his login. Not gating anything, since the database restore is the real path.

fail2ban. SSH is being brute-forced from the open internet; ordinary background noise, but it is cheap insurance and key-only auth is already on.

Old kernels. 5.15.0-1030 and 6.8.0-1060 are still installed; autoremove will not take them while the linux-image-oracle meta-package holds a reference. Keeping one fallback kernel is sensible anyway.

The clock that is no longer ticking

The original deadline behind all of this was Ubuntu 22.04 losing standard support in April 2027. That is gone: 24.04 LTS is supported to 2029, and because Mastodon no longer depends on what the OS ships, the next release upgrade is an ordinary do-release-upgrade rather than a rebuild.

Revision 3, 7 September 2026. Revision 1 was a plan compiled from a read-only inspection; revision 2 recorded the first phases; this revision records the completed migration and serves as the operating reference. Where an earlier estimate proved wrong it is corrected in section 04 rather than quietly overwritten.

All figures are measured, not estimated: from the running host, from the database, and from a restore of the backup into a scratch database. Release gates come from the Mastodon release notes for v4.2.0 through v4.7.0 and the v4.7.1 Dockerfile; arm64 image availability was confirmed against the GHCR manifests for every hop in the chain before starting, not just the destination.

Leave a Reply

Your email address will not be published. Required fields are marked *