Self-hosting
Update, back up, and restore
Update one release at a time, keep backups of the database, uploads, and keys together, and rotate keys without losing data.
Update to a new release
Before starting the new version or rolling back, keep web and the scheduler stopped until more than five minutes have passed since the last publish started. Do not run old and new web processes together: older versions do not recognize the recovery wait for an unconfirmed publish. Each publish has a four-minute work budget; after an attempted repository change fails, new publish and sync attempts are blocked until five minutes after its start. You can keep editing. Revert needs a new confirmed publish after that wait. This timeout cannot roll back requests already received by GitHub.
Update one release at a time, in order, and only to the latest release; updates that skip releases haven't been verified yet. The migrations must finish before web is recreated, so an old app never reads a newer database. Outside traffic and the scheduler stay off until the new version passes your checks: if you have to go back to the backup, nothing anyone saved in between is lost.
- Stop the app and take a backup with the backup commands, and go on only if it printed
backup ok. Don't rundocker compose up -dafterwards. - Compare the new tag's
deploy/with yours (leave.envalone), carry over changes to Compose, nginx, and the scheduler, and setMALMOI_IMAGEin.envto the new tag and digest. Ifdeploy/scheduler/changed, rundocker compose build scheduler. - Run
docker compose pull --ignore-buildable. The scheduler image is built locally and isn't in the registry, so the command fails without--ignore-buildable. - Run
docker compose run --rm migrate. It applies the new migrations and runs the bootstrap again, which is safe to repeat. Don't count on a migrate run from an earlierup. - Limit ports 80 and 443 to your own IP address in your cloud provider's firewall. A firewall on the server itself isn't enough, because Docker's published ports bypass it.
- Run
docker compose up -d --force-recreate --no-deps web proxy.--no-depskeeps migrate from running again, and the scheduler stays stopped. - Check the new version from your own browser:
docker compose psshows web healthy, you can sign in, a translation screen opens, uploaded pictures show, anddocker compose logs web --since 5mhas noEACCESorpreflight:lines. Don't publish from it yet. - If everything works, run
docker compose up -d --no-deps schedulerand open ports 80 and 443 to everyone again. Workflow runs since step 1 couldn't reach the server; rerun them with Run workflow. - If something is wrong, keep the ports limited. Go back to the previous
MALMOI_IMAGEand thedeploy/files from before step 2, and repeat steps 4 and 6, only if the old version is known to work with the new database. Otherwise restore the backup from step 1 — database, image, and keys together (restore). There are no down migrations.
Back up
A backup is three things taken while the app is stopped: the database dump, the upload volume, and .env (the keys). If any one is from a different moment, the restore doesn't line up — database rows point at uploaded files, and the encrypted values open only with the keys from that time. Keep backups outside the repository checkout and copy them off the server. Permissions are 600 for files and 700 for directories. Run the commands as root from the deploy/ directory: the upload archive is written by a container as root, and /var/backups needs root. The part in parentheses stops at the first command that fails; trust the backup only when it ends with backup ok.
Run this block on its own: do not wrap it in if, &&, or ||, which can disable its stop-on-error behavior.
( set -eu
STAMP=$(date -u +%Y%m%dT%H%M%SZ); B=/var/backups/malmoi/$STAMP
docker compose stop scheduler proxy web # stops new traffic and writes; only postgres keeps running
mkdir -p "$B"
chmod 700 "$B"
docker compose exec -T postgres pg_dump -U postgres -d malmoi -Fc > "$B/db.dump"
docker compose exec -T postgres pg_restore -l < "$B/db.dump" > /dev/null # the dump can be read back
docker run --rm -v malmoi_uploads:/data:ro -v "$B":/backup alpine:3.22 tar czf /backup/uploads.tar.gz -C /data .
cp .env "$B/env"
cp -r certs nginx "$B/"
{ echo "taken_at=$STAMP"; grep '^MALMOI_IMAGE=' .env
docker compose exec -T postgres psql -U postgres -d malmoi -Atc 'select count(*), max(migration_name) from _prisma_migrations'
} > "$B/manifest.txt"
(cd "$B" && sha256sum db.dump uploads.tar.gz env >> manifest.txt)
chmod -R go-rwx "$B" # last, so the manifest is covered too
echo "backup ok: $B"
)- The manifest holds no secrets: the time, the image, the migration state, and the checksums.
- For a routine backup, start everything again with
docker compose up -d. When the backup is the first step of an update, leave the app stopped. - Only the web container writes to the upload volume. Don't mount it into another service or let another process on the server write to it: a second writer could make web serve files from outside the volume. The backup reads it read-only, and the restore writes to it while web is stopped.
- Keep the keys with the dump. Without the PII key, emails and names can't be recovered; without the token key, GitHub connections can't. After a key rotation, keep the old keys too, because older backups need them.
- If you turned on
log_statement(ddlorall) orpg_stat_statementswithtrack_utility, the server log or statistics keep theCREATE ROLE … PASSWORDstatements. The stock postgres image has both off; if you enabled them, turn them off while the bootstrap runs. - A backup counts only once a restore from it has worked. Try the restore below once on another server, with the scheduler left off. Limit anything that writes to a real repository (Publish, the nightly sync) to a test repository there.
Restore into empty volumes
This brings a backup back on a new server, or after docker compose down -v. The keys must be the ones from the backup; other keys can't open the encrypted values.
Restore drills and isolated checks belong on a different server from your live one. deploy/compose.yaml fixes the Compose project name, so on the same server a copied directory still uses the live malmoi_pgdata and malmoi_uploads volumes: steps 2 and 4 would run on your live data, and down -v would delete it. If you have to use the same server, add -p <another name> to every Compose command, change the volume name in step 4 to <that name>_uploads, and give the proxy different ports — missing any one of these hits the live installation.
Before step 1, limit every published proxy port to your own IP address in your cloud provider's firewall: ports 80 and 443 in the supplied configuration, or the different ports you chose for an isolated restore. A firewall on the server itself is not enough because Docker's published ports bypass it. Keep this restriction until step 8, including while checking sign-in; restored credentials must not be publicly usable before the review.
- Get the same tag's
deploy/, copy the backup'senvtodeploy/.env, and bring backcerts/andnginx/. KeepMALMOI_IMAGEat the backup's digest, or a newer tag you've confirmed compatible. Fromdeploy/, runread -r Band enter the absolute path of the backup directory you want to restore. Rundocker compose stop scheduler proxy weband continue only if it succeeds. - Run
docker compose up -d postgres. On an empty volume this creates the database and the migration role withMIGRATE_DB_PASSWORDfrom.env. - Load the dump as the migration role, without owners or permissions (the migration role becomes the owner, and step 5 grants permissions again):
docker compose exec -T postgres pg_restore -U malmoi_migrate -d malmoi --no-owner --no-acl < "$B/db.dump". Read the closingerrors ignored on restore: N. Onlyalready existsmessages for thepublicschema are expected; stop on anything else rather than migrating a partial restore. - Restore the uploads:
docker volume create malmoi_uploads && docker run --rm -v malmoi_uploads:/data -v "$B":/backup alpine:3.22 sh -c 'tar xzf /backup/uploads.tar.gz -C /data && chown -R 1000:1000 /data'(the app usernodeis uid 1000). From now on every Compose command warns that the volumealready exists but was not created by Docker Compose. It's harmless, anddown -vstill removes the volume. - Run
docker compose run --rm migrate. If the restored migration history is current, nothing is migrated, and the bootstrap creates the runtime role, grants its permissions, and takes schema access away from everyone else again. Always run this after a restore — skipping it leaves the restored database open to every role. - Before starting web, sign everyone out with
docker compose exec -T postgres psql -X -v ON_ERROR_STOP=1 -U postgres -d malmoi -c 'DELETE FROM "Session"'. Continue only if it succeeds. Rundocker compose up -d --no-deps web proxyand leave the scheduler off. From an allowed IP address, check that you can sign in, see a project's translations, and see uploaded pictures, and thatdocker compose logs webhas no decryption errors (credential-…). - Keep access restricted while you check what the backup brought back. Personal tokens, connected apps, project push tokens, removed members, and canceled invitations can work again. Let only trusted reviewers' IP addresses through the cloud firewall while users revoke restored credentials on the MCP page and project owners remove unwanted members, cancel invitations, and rotate affected push tokens. Reconcile these with your records from after the backup; if you cannot finish the review, keep public access closed. Edits and Publish runs after the backup are lost; compare with the pull requests in the target repositories.
- Only after those checks, if this installation is now your live one, run
docker compose up -d --no-deps schedulerand open ports 80 and 443 to everyone again. On a test server, keep the scheduler off and access restricted.
Rotate keys
The key tools run inside the app image and connect as the migration role through DIRECT_URL. No Compose service gets both the database admin credentials and the six keys, so you pass DIRECT_URL from your shell. Never type a password into a command line, where shell history and ps keep it: keep it in a shell variable and pass -e DIRECT_URL without a value.
-
Take a backup. It pairs the database with the old keys.
-
In
deploy/.env, add the new key to the key ring next to the old one and set*_ACTIVE_KEY_IDto the new key's name. For the lookup key, replaceEMAIL_LOOKUP_KEYandEMAIL_LOOKUP_KEY_ID; the old one isn't needed. The new values must all differ. -
Block traffic and stop writers:
docker compose stop proxy scheduler web. Without the proxy nothing outside gets in (browsers, target repositories' workflows, coding agents, sign-in callbacks), and without the scheduler there's no nightly sync. Only postgres keeps running. -
Put
DIRECT_URLin your shell without writing it to history:read -rs P && export DIRECT_URL="postgresql://malmoi_migrate:${P}@postgres:5432/malmoi" && unset P, typingMIGRATE_DB_PASSWORDat the prompt. Skipping TLS is fine only while the host is exactlypostgres; for a database on another host, add?sslmode=verify-full. Other query parameters are rejected. -
Check, apply, and verify.
--no-depskeeps migrate from running:RUN='docker compose run --rm --no-deps -e DIRECT_URL web pnpm credentials:self-hosted' $RUN --mode=rotate-token # check only $RUN --mode=rotate-token --apply --traffic-blocked --writers-drained $RUN --mode=rotate-pii --apply --traffic-blocked --writers-drained $RUN --mode=reindex --apply --traffic-blocked --writers-drained # only if you changed the lookup key $RUN --mode=verifyEach prints one line of JSON.
oldTokenKeyandoldPiiKeyfromverifymust both be 0 before you start the app with the new keys. A failure prints onlycredential-conversion-failed: keep traffic blocked, without values; the stderr line just before it,[credentials] … credential-env: missing environment variable <name>, says why (an active key ID that's empty or not in its key ring). Never start the app again with only part of the data converted. -
Run
unset DIRECT_URL, thendocker compose up -d --force-recreate --no-deps web proxy schedulerso web reads the new.env, and check sign-in and invitations. -
Keep the old keys in the key ring. Older backups need them.
Other secrets and checks:
pnpm credentials:finalize:self-hosted(sameRUNform,--mode=backfillby default) only reads: it confirms that the credential storage migration has been applied and prints{"target":"self-hosted","pending":false,"applied":false}. A new installation or a normal update always showspending:false, because the migrate service applies every migration. Its--applyruns the migrations directly and isn't part of the normal procedure; updates usedocker compose run --rm migrate.APP_SIGNING_SECRETandAUTH_SECRET: change.envand rundocker compose up -d --force-recreate web. No traffic block is needed; only sign-ins and GitHub connections in progress at that moment start over.CRON_SECRETmust match in web and the scheduler, so recreate both (--force-recreate web scheduler); if only one changes, the nightly sync gets 401.- Database passwords: once the volume exists, changing
MIGRATE_DB_PASSWORDorRUNTIME_DB_PASSWORDin.envalone does nothing. Opendocker compose exec postgres psql -U postgres -d malmoi -X, run\password malmoi_app(ormalmoi_migrate— psql hashes the password before sending it, so it doesn't reach the server log), then update.envand rundocker compose up -d --force-recreate web. The migration role's new password applies from the nextdocker compose run --rm migrate.
Turn a sign-in provider off
Keep at least one provider on. Turning one off hides its button and its row under Sign-in methods; it doesn't delete anything, and turning it back on brings the linked methods back.
- Ask everyone who signs in with that provider to connect one that stays on: Account → Sign-in methods → Connect. The other provider has to verify the same email address.
- Empty both values of the provider in
deploy/.env(for exampleAUTH_GITHUB_ID=andAUTH_GITHUB_SECRET=). - Run
docker compose up -d --force-recreate weband check that the sign-in screen shows only the providers that are on.
Sessions that are already open stay signed in, and those people can still connect a remaining provider from Account. Someone who had only the provider you turned off sees “Your sign-in method isn't available here. Ask your administrator.” when signing in. To let them in, fill in the pair again, recreate web, have them connect another provider, and turn it off again.
What happens next
After an update or a restore, check the web log for preflight: lines and decryption errors (troubleshooting), and compare the stored fields with your privacy policy (privacy materials).