Dry-running CUTOVER.md against production surfaced four things that would have failed mid-cutover: - The volume is named after the compose project, which is the lowercased directory name (mangabookmark), not the repo name. Both runbooks hardcoded bookmarkmanager_bookmarks-data. Derive it from docker instead. - §1 asserted a clean stop leaves no -wal. compose stop SIGKILLs after 10s, and a surviving -wal holds writes bookmarks.db alone does not, so the export would silently lose them. Check rather than assume. - jq is not installed on the server; §5's read-path check now uses python3, which the runbook already requires. - REDEPLOY §1 still listed the pre-split table set, omitting readers and sessions from both the \dt output and the pg_restore contents. Also records the git-pull failure the redeploy hits on a checkout whose remote is the HTTPS clone URL.
19 KiB
Redeploy runbook
Shipping new code to a server that is already running. First-time setup (DNS,
.env, Traefik, installing the userscript) is DEPLOY.md — this file assumes
all of that exists and picks up at "there is a running stack and I want it to
run the new commit."
Whole thing is ~5 minutes, most of it waiting on docker build. Order matters:
back up before you pull. A backup taken after a bad migration is a backup of
the damage.
Paths below assume the checkout is at /opt/bookmarkmanager; substitute your own. The
one absolute rule about paths: backups live in ../bookmarkmanager-backups/, a
sibling of the project directory (/opt/bookmarkmanager-backups), never inside it. It
sits outside the repo so git pull, git clean -fd and a bad rm -rf inside
the checkout cannot take the backups with them.
/opt/
├── bookmarkmanager/ <- the checkout (this repo)
└── bookmarkmanager-backups/ <- bookmarks-YYYYmmdd-HHMMSS.dump
0. Preflight
cd /opt/bookmarkmanager
# Both -f flags, every time. The prod override is not standalone.
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
$COMPOSE ps # bookmark-api should be Up
git status --short # expect empty
git log --oneline -1 # note this hash — it is your rollback target
df -h /var/lib/docker | tail -1 # a build needs room
If git status is dirty, someone edited files on the server. Decide before you
pull: git stash to keep it, git checkout -- . to discard. A git pull onto a
dirty tree fails halfway and leaves you in a worse spot than either.
Create the backup directory once, and make sure it is a sibling, not a child:
mkdir -p ../bookmarkmanager-backups
BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups" # absolute — Docker needs it
echo "$BACKUP_DIR" # -> /opt/bookmarkmanager-backups
1. Back up the database
The database is Postgres, running as the postgres service on the named volume
postgres-data. It has no published port — nothing outside the internal db
network can reach it — so every command below goes in through the container:
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c '\dt'
# -> bookmarks, readers, schema_migrations, series, sessions
Inside the container that connects over the local socket as the bookmarks
superuser, so no password is needed anywhere in this section. -T is not
optional: without it Compose allocates a TTY, which rewrites \n to \r\n and
silently corrupts any binary stream flowing back out — see the dump below.
Preferred: hot dump, no downtime
pg_dump runs in a single repeatable-read transaction, so it writes one
point-in-time-consistent snapshot while the API keeps serving. No stopping, no
WAL to worry about — that is the server's problem, not yours.
STAMP=$(date -u +%Y%m%d-%H%M%S) # UTC, sorts chronologically as text
$COMPOSE exec -T postgres pg_dump -U bookmarks -d bookmarks -Fc \
> "$BACKUP_DIR/bookmarks-$STAMP.dump"
ls -lh "$BACKUP_DIR"/bookmarks-$STAMP.dump
-Fc is the custom archive format rather than plain SQL: it is compressed, and
pg_restore can inspect and replay it selectively — list its table of contents,
restore one table, restore schema without data, reorder. A plain .sql dump can
only be piped into psql whole, and gives you no way to check what is in it
short of reading it.
$STAMP is the "time in the name" — bookmarks-20260730-014233.dump. UTC, so
the files sort in real order and never collide across a DST shift.
Verify it before you trust it. An unreadable backup is worse than none, because you will act as though you have one:
# 1. The dump parses and contains the tables. Uses the same image compose
# already pulls, so nothing new to install.
docker run --rm -v "$BACKUP_DIR":/backup postgres:17-alpine \
pg_restore --list "/backup/bookmarks-$STAMP.dump" | grep 'TABLE DATA'
# -> 1234; 0 0 TABLE DATA public bookmarks bookmarks
# -> 1235; 0 0 TABLE DATA public readers bookmarks
# -> 1236; 0 0 TABLE DATA public schema_migrations bookmarks
# -> 1237; 0 0 TABLE DATA public series bookmarks
# -> 1238; 0 0 TABLE DATA public sessions bookmarks
# 2. Sanity-check the live row count you just captured.
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks \
-c 'select count(*) from bookmarks'
# -> 37
A custom-format archive stores row counts nowhere, so step 1 proves the file is
a readable archive with the right tables in it, not that the rows are there;
step 2 is the number those rows should be. It should match what the web UI
shows. Zero on a server you know has bookmarks means the API and your psql
are looking at different databases — check DATABASE_URL.
Fallback: cold volume archive
Use this when you want the whole data directory rather than a logical dump — a like-for-like restore of the same Postgres major version onto the same host.
The stack must be stopped first. A running Postgres has dirty pages in
shared buffers and WAL that has not been replayed into the data files, and tar
walks the directory over several seconds while the server keeps writing to it.
The archive you get is torn: files from different instants, possibly a
half-written page. It may restore, start, and be quietly wrong. Online
filesystem-level backup is pg_basebackup's job, not tar's; with the
container stopped the shutdown checkpoint has already flushed everything and a
plain archive of the volume is consistent.
VOL=$(docker volume ls --filter name=postgres-data -q | head -1)
echo "$VOL" # -> bookmarkmanager_postgres-data
$COMPOSE stop
docker run --rm -v "$VOL":/from:ro -v "$BACKUP_DIR":/to alpine \
tar czf "/to/postgres-data-$STAMP.tgz" -C /from .
$COMPOSE start
ls -lh "$BACKUP_DIR"/postgres-data-$STAMP.tgz
Costs ~15 seconds of downtime. Read-only on the source is safe here precisely
because nothing is running against it. Restoring this variant means untarring it
back into an empty postgres-data volume with the stack down — it is a whole
data directory, not a file you can drop next to the live one, and it will only
start under postgres:17.
Retention
Keep a month, drop the rest — a bookmark database this small compresses the decision to "disk is free, but not infinite":
ls -1t "$BACKUP_DIR"/bookmarks-*.dump | tail -n +31 | xargs -r rm -v
A note on the old bookmarks-data volume
bookmarks-data is the pre-migration SQLite volume. It is deliberately not
declared in docker-compose.yml any more, which is what keeps docker compose down -v from taking it with the rest of the stack. It is not the live database
and nothing reads it — the one-way move out of it is CUTOVER.md. Once the
Postgres data has been trusted for a while, remove it by hand — nothing else will.
Its full name is <compose project>_bookmarks-data, and the project name is the
lowercased directory name of the checkout, so ask Docker rather than typing it:
docker volume rm "$(docker volume ls -q --filter name=_bookmarks-data)"
2. Pull the new code
git pull --ff-only
git log --oneline -3
--ff-only so a diverged history fails loudly instead of opening a merge you
did not plan on the production box.
Check whether .env needs anything new. New config lands in .env.example, and
compose fails at start for a missing required var — better to find out now:
git diff HEAD@{1} HEAD -- .env.example docker-compose.yml docker-compose.prod.yml
If a variable was added there, add it to .env before continuing.
3. Rebuild and restart
$COMPOSE up -d --build
A rebuild is mandatory for any UI change. The HTML templates, CSS,
JavaScript and fonts are compiled into the binary by //go:embed, so editing
them on the server — or pulling them — changes nothing until the image is
rebuilt. The one exception is userscript/manga-bookmark.user.js, which is
bindmounted read-only and read fresh per request.
$COMPOSE ps # bookmark-api Up; postgres Up (healthy)
docker logs bookmark-api --tail 20 # -> "listening on :8080 ..."
Nothing in the log about the database, the migrations or the poller failing.
bookmark-api waits on postgres reporting healthy before it starts and the
binary applies any pending migration before it listens, so an API that never
says "listening" is usually the database, not the code — $COMPOSE logs postgres first. The image is tagged
bookmarkmanager-backend:latest, so the previous image is still on disk untagged —
that is what makes the rollback in §6 quick.
4. Verify the deploy
Same four API checks as DEPLOY.md §3, plus the web UI. Set the host names once:
API=https://bookmark-api.violetcrown.my.id
WEB=https://bookmark.violetcrown.my.id
# During the grace window the retired global credential still resolves to the
# owner; afterwards it is 401 like any other wrong credential.
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
curl -s $API/healthz # -> ok
curl -s -o /dev/null -w '%{http_code}\n' $API/bookmarks # -> 401
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200
# -> your data, not []
curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \
-H 'Access-Control-Request-Method: PUT' \
$API/bookmarks/x | grep -i access-control # -> allow-origin echoed
[] from the third call is the alarm that matters: you are talking to an empty
database, which means the API found a different Postgres than the one holding
your data — a renamed project directory, a fresh postgres-data, or a
DATABASE_URL override in .env pointing elsewhere. Stop and check, before
touching anything else:
$COMPOSE config --volumes # -> postgres-data
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c 'select count(*) from bookmarks'
Web UI and its assets:
curl -s -o /dev/null -w '%{http_code}\n' $WEB/ # -> 200 (login page)
curl -s -o /dev/null -w '%{http_code}\n' $WEB/static/style.css # -> 200
# The fonts are self-hosted; this is what tells you they shipped and are typed.
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
$WEB/static/fonts/instrument-serif-400-latin.woff2
# -> 200 font/woff2
# The served userscript, whose @version is its mtime — a pull bumps it.
curl -s $API/u/$TOKEN/manga-bookmark.user.js | grep '@version'
404 on the font means static/fonts/ did not make it into the image; the UI
will still render, in Georgia, which is easy to miss on a phone. application/ octet-stream instead of font/woff2 means an older binary is running.
Then open $WEB in a browser and confirm, in one glance:
- Serif brand and serif row titles — not the system sans fallback.
- A series with an unread chapter has a crimson title on an ember underline;
everything else is cool grey. That single detail exercises the whole design
path (template class, CSS, and the poller's
latest_chapter). - Tapping the pencil opens the chapter form; Save closes it and the row keeps its place in the list.
5. Smoke-test the full loop
The API answering is not the same as the product working. Do this on the phone, against the real sites — it is the only check that covers the userscript, CORS and the shared store together:
- Open a series on asurascans.com, open the panel, + Bookmark this.
- On the server:
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks→ the series is in the JSON. - Open a chapter of it, reopen the panel → last-read shows that chapter, and opening an older chapter does not move it backwards.
- Open demonicscans.org, open the panel → the Asura bookmark is listed there too. Different origin, one store — this is the whole point of the backend, and the check that fails first when CORS or TLS regressed.
- Open
$WEB→ the same series appears, with the same chapter. - Turn airplane mode on, tap ★ on a row, turn it off, reopen the panel → the star stuck. That exercises the retry queue.
If 1–5 pass, the deploy is good.
6. Rollback
Two independent things can be wrong, so undo only what broke.
Bad code, database fine — go back to the previous commit and rebuild:
git log --oneline -5
git checkout <previous-hash>
$COMPOSE up -d --build
Database damaged — restore the dump from §1. Stop only the API, not the
whole stack: pg_restore needs the server up to restore into, and it needs
bookmark-api's connection pool gone, because --clean cannot drop a table
other sessions are holding open.
$COMPOSE stop bookmark-api
$COMPOSE exec -T postgres pg_restore -U bookmarks -d bookmarks --clean --if-exists \
< "$BACKUP_DIR/bookmarks-<STAMP>.dump"
$COMPOSE start bookmark-api
docker logs bookmark-api --tail 20
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200
Three things here are easy to skip and all three bite:
--clean --if-exists. Without--cleanthe dump's rows land on top of what is already there and you get primary-key collisions half way through, a partially restored database, and a non-zero exit you may not notice.--if-existsonly suppresses the "does not exist" noise when the target is already empty; it is not the part doing the work.-Tagain. Feeding a custom-format archive into a TTY-allocatedexeccorrupts it in flight andpg_restorefails with a garbled-header error on a file that is perfectly fine on disk.- Stop the API, not Postgres.
$COMPOSE stop(everything) leaves you with nothing to restore into; leavingbookmark-apirunning leaves connections that block the drops and lets the poller write into a half-restored table.
No ownership fixing is needed any more — the Postgres image owns postgres-data
itself and pg_restore writes through the server, not the filesystem.
schema_migrations is inside the dump, so the database comes back at whatever
schema version the backup was taken at; the migration runner applies anything
newer the next time bookmark-api starts.
7. The whole thing, as one block
For a routine redeploy where nothing needs deciding:
cd /opt/bookmarkmanager
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups"; mkdir -p "$BACKUP_DIR"
STAMP=$(date -u +%Y%m%d-%H%M%S)
$COMPOSE exec -T postgres pg_dump -U bookmarks -d bookmarks -Fc \
> "$BACKUP_DIR/bookmarks-$STAMP.dump" &&
docker run --rm -v "$BACKUP_DIR":/backup postgres:17-alpine \
pg_restore --list "/backup/bookmarks-$STAMP.dump" > /dev/null &&
git pull --ff-only &&
$COMPOSE up -d --build &&
sleep 5 &&
curl -sf https://bookmark-api.violetcrown.my.id/healthz && echo " deploy ok"
The && chain is deliberate: if the dump or its pg_restore --list check
fails, nothing is pulled and nothing is rebuilt. A failed dump still leaves a
short or empty .dump behind — the shell creates the file before pg_dump
runs — so delete it rather than letting it sit in the backup directory looking
like a backup. Then still do §5 by hand — no shell command can tell you the
panel works on the phone.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
/bookmarks returns [] after redeploy |
You are on an empty Postgres. Check $COMPOSE config --volumes lists postgres-data, that you passed both -f files, and that .env has no stray DATABASE_URL override. Do not re-bookmark; the data is still in the volume. |
| UI looks like plain Georgia / system sans | static/fonts/ missing from the image, or the browser cached an old style.css. /static/* is served max-age=3600, so hard-reload or wait an hour. |
| CSS or template change did not appear | You restarted without --build. Assets are //go:embeded. |
Font answers application/octet-stream |
Old binary — the .woff2 MIME registration is in web.go. Rebuild. |
| Everyone logged out of the web UI | The sessions table was wiped; sessions are database rows, not signed cookies. Expected after a deliberate revoke. |
compose errors about BOOKMARK_WEB_HOST |
Run from the directory holding .env. Both host vars are required even when the web UI is unused. |
| Userscript did not update on the phone | Violentmonkey polls on its own schedule; force a check. @version comes from the file's mtime, so confirm the pull actually touched it. |
bookmark-api crash-loops, log says password authentication failed for user "bookmarks" |
POSTGRES_PASSWORD in .env no longer matches the one burned into postgres-data at first init — Postgres reads that variable only when initialising an empty volume. Put the old value back, or reset the role: $COMPOSE exec postgres psql -U bookmarks -d bookmarks -c '\password bookmarks' (prompts, so nothing lands in shell history) and then match .env to it. |
compose errors set POSTGRES_PASSWORD in .env |
Unset. Compose builds the backend's DATABASE_URL out of it, so it is required even though you never write that URL yourself. Run from the directory holding .env. |
postgres never leaves starting; bookmark-api never starts either |
The healthcheck (pg_isready) is failing and bookmark-api waits on it. $COMPOSE logs postgres — usually postgres-data was initialised by a different major version ("database files are incompatible with server"), or the disk is full. |
pg_restore: cannot drop … other objects depend on it / being accessed by other users |
Live connections block --clean. $COMPOSE stop bookmark-api first (§6). If they persist: $COMPOSE exec -T postgres psql -U bookmarks -d postgres -c "select pg_terminate_backend(pid) from pg_stat_activity where datname='bookmarks' and pid <> pg_backend_pid()". |
Dump is 0 bytes, or pg_restore: did not find magic string in file header |
You ran exec without -T. The allocated TTY rewrites newlines in the binary stream and corrupts the archive in flight (§1). |
git pull: could not read Username for 'https://…' |
The checkout's remote is the HTTPS clone URL and the server has no credential helper, so the pull prompts into a closed stdin. Switch it to SSH once — git remote set-url origin ssh://git@gitea.violetcrown.my.id:2222/sulthan/mangaBookmark.git. Gitea's SSH listens on 2222, not 22; port 22 is the host's own sshd and answers Permission denied (publickey) no matter which key is registered. |
Full first-time setup: DEPLOY.md. The one-off SQLite→Postgres move:
CUTOVER.md. Config reference and endpoints: README.md.
UI conventions: docs/design-system.md.