Add a redeploy runbook
DEPLOY.md covers standing the stack up from nothing and ends with a two-line "Updating" section, which is the operation actually performed every time and the one where the irreversible mistakes live. Redeploying has a required order — back up before pulling, because a backup taken after a bad deploy is a backup of the damage — and three traps that are invisible until they cost data: - The store runs in WAL mode, so copying bookmarks.db alone while the container is up can silently drop the newest bookmarks. VACUUM INTO folds the WAL in; the cold-copy fallback has to take the sidecar files. - That backup needs the source volume mounted read-write, which looks wrong. Opening a WAL database creates the -shm file, so :ro fails outright. - A restored file lands root-owned while the container runs as uid 65532. Reads succeed and writes do not, so the restore looks like it worked. Backups go to ../mangabm-backups/, a sibling of the checkout rather than a directory inside it, so no git operation or careless rm in the project dir can take the backups along with it. Names carry a UTC timestamp so they sort chronologically as plain text and cannot collide across a DST shift. Also documents that a rebuild is mandatory for any UI change now that the templates, CSS and fonts are //go:embed-ed, and ends at the phone smoke test: no curl can tell you the panel works on the device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+342
@@ -0,0 +1,342 @@
|
|||||||
|
# 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/mangabm`; substitute your own. The
|
||||||
|
one absolute rule about paths: **backups live in `../mangabm-backups/`**, a
|
||||||
|
sibling of the project directory (`/opt/mangabm-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/
|
||||||
|
├── mangabm/ <- the checkout (this repo)
|
||||||
|
└── mangabm-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Preflight
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/mangabm
|
||||||
|
|
||||||
|
# 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 # manga-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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ../mangabm-backups
|
||||||
|
BACKUP_DIR="$(cd .. && pwd)/mangabm-backups" # absolute — Docker needs it
|
||||||
|
echo "$BACKUP_DIR" # -> /opt/mangabm-backups
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Back up the database
|
||||||
|
|
||||||
|
The database is a single SQLite file in the named Docker volume, at
|
||||||
|
`/data/bookmarks.db` inside the container. Find the volume's real name — Compose
|
||||||
|
prefixes it with the project directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker volume ls --filter name=bookmarks-data
|
||||||
|
# -> local mangabm_bookmarks-data
|
||||||
|
VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preferred: hot backup, no downtime
|
||||||
|
|
||||||
|
The store runs in **WAL mode**, so recent writes may still be sitting in
|
||||||
|
`bookmarks.db-wal`. Copying `bookmarks.db` alone while the container runs can
|
||||||
|
therefore silently drop the newest bookmarks. `VACUUM INTO` folds the WAL in and
|
||||||
|
writes one consistent file, safe to run against a live database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
STAMP=$(date -u +%Y%m%d-%H%M%S) # UTC, sorts chronologically as text
|
||||||
|
docker run --rm \
|
||||||
|
-v "$VOL":/data \
|
||||||
|
-v "$BACKUP_DIR":/backup \
|
||||||
|
alpine sh -c "apk add -q sqlite &&
|
||||||
|
sqlite3 /data/bookmarks.db \"VACUUM INTO '/backup/bookmarks-$STAMP.db'\""
|
||||||
|
|
||||||
|
ls -lh "$BACKUP_DIR"/bookmarks-$STAMP.db
|
||||||
|
```
|
||||||
|
|
||||||
|
`$STAMP` is the "time in the name" — `bookmarks-20260730-014233.db`. UTC, so the
|
||||||
|
files sort in real order and never collide across a DST shift.
|
||||||
|
|
||||||
|
Note the source volume is mounted **read-write**, which looks wrong for a backup
|
||||||
|
and is not. Opening a WAL database requires creating the `-shm` shared-memory
|
||||||
|
file; with `:ro` the command fails with `unable to open database file` and no
|
||||||
|
backup is produced. `VACUUM INTO` never writes to the source itself.
|
||||||
|
|
||||||
|
Verify it before you trust it. An unreadable backup is worse than none, because
|
||||||
|
you will act as though you have one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$BACKUP_DIR":/backup alpine sh -c "apk add -q sqlite &&
|
||||||
|
sqlite3 /backup/bookmarks-$STAMP.db 'PRAGMA integrity_check;' &&
|
||||||
|
sqlite3 /backup/bookmarks-$STAMP.db 'SELECT count(*) FROM bookmarks;'"
|
||||||
|
# -> ok
|
||||||
|
# -> 37
|
||||||
|
```
|
||||||
|
|
||||||
|
The count should match what the web UI shows. Zero rows on a server you know has
|
||||||
|
bookmarks means you backed up the wrong volume.
|
||||||
|
|
||||||
|
### Fallback: cold copy (no network for `apk add sqlite`)
|
||||||
|
|
||||||
|
Stop the service first, then copy the database **and its sidecars** — the `-wal`
|
||||||
|
is not optional, it is where the newest writes are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$COMPOSE stop
|
||||||
|
docker run --rm -v "$VOL":/data:ro -v "$BACKUP_DIR":/backup alpine sh -c "
|
||||||
|
cp /data/bookmarks.db /backup/bookmarks-$STAMP.db
|
||||||
|
[ -f /data/bookmarks.db-wal ] && cp /data/bookmarks.db-wal /backup/bookmarks-$STAMP.db-wal
|
||||||
|
[ -f /data/bookmarks.db-shm ] && cp /data/bookmarks.db-shm /backup/bookmarks-$STAMP.db-shm
|
||||||
|
ls -1 /backup"
|
||||||
|
$COMPOSE start
|
||||||
|
```
|
||||||
|
|
||||||
|
Costs ~10 seconds of downtime. A clean shutdown usually checkpoints the WAL away,
|
||||||
|
so seeing only the `.db` file is normal and fine — the `[ -f ]` guards exist for
|
||||||
|
the case where it did not. Restoring this variant means putting whichever files
|
||||||
|
you got back together, under their original names.
|
||||||
|
|
||||||
|
Read-only is safe here precisely because nothing opens the database: it is a file
|
||||||
|
copy, not a SQLite connection.
|
||||||
|
|
||||||
|
### Retention
|
||||||
|
|
||||||
|
Keep a month, drop the rest — a bookmark database this small compresses the
|
||||||
|
decision to "disk is free, but not infinite":
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -1t "$BACKUP_DIR"/bookmarks-*.db | tail -n +31 | xargs -r rm -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pull the new code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$COMPOSE ps # Up, and recently (re)created
|
||||||
|
docker logs manga-api --tail 20 # -> "listening on :8080 ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing in the log about the database or the poller failing. The image is tagged
|
||||||
|
`mangabm-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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API=https://manga-api.violetcrown.my.id
|
||||||
|
WEB=https://manga.violetcrown.my.id
|
||||||
|
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: the volume is not attached
|
||||||
|
and you are looking at an empty database. Stop and check `$COMPOSE config
|
||||||
|
--volumes` before touching anything else.
|
||||||
|
|
||||||
|
Web UI and its assets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
1. Open a series on **asurascans.com**, open the panel, **+ Bookmark this**.
|
||||||
|
2. On the server: `curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks`
|
||||||
|
→ the series is in the JSON.
|
||||||
|
3. Open a chapter of it, reopen the panel → last-read shows that chapter, and
|
||||||
|
opening an *older* chapter does not move it backwards.
|
||||||
|
4. 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.
|
||||||
|
5. Open `$WEB` → the same series appears, with the same chapter.
|
||||||
|
6. 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -5
|
||||||
|
git checkout <previous-hash>
|
||||||
|
$COMPOSE up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database damaged** — restore the backup from §1. Stop first: the running
|
||||||
|
process holds the WAL, and dropping a file under a live SQLite connection
|
||||||
|
corrupts what you were trying to save.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$COMPOSE stop
|
||||||
|
|
||||||
|
docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c '
|
||||||
|
rm -f /data/bookmarks.db /data/bookmarks.db-wal /data/bookmarks.db-shm &&
|
||||||
|
cp /backup/bookmarks-<STAMP>.db /data/bookmarks.db &&
|
||||||
|
chown 65532:65532 /data/bookmarks.db &&
|
||||||
|
ls -l /data'
|
||||||
|
|
||||||
|
$COMPOSE start
|
||||||
|
docker logs manga-api --tail 20
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Two steps here are easy to skip and both bite:
|
||||||
|
|
||||||
|
- **Delete the stale `-wal` and `-shm`.** Leaving them beside a restored database
|
||||||
|
mixes two different histories; SQLite will either refuse to open it or quietly
|
||||||
|
reapply writes you meant to discard.
|
||||||
|
- **`chown 65532:65532`.** The image is `distroless/static:nonroot` and runs as
|
||||||
|
that uid, while the helper container above writes as root. A root-owned
|
||||||
|
database opens read-only-ish: reads work, so `/bookmarks` looks fine, and then
|
||||||
|
every write fails. That is the worst possible failure mode — it looks restored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. The whole thing, as one block
|
||||||
|
|
||||||
|
For a routine redeploy where nothing needs deciding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/mangabm
|
||||||
|
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||||
|
BACKUP_DIR="$(cd .. && pwd)/mangabm-backups"; mkdir -p "$BACKUP_DIR"
|
||||||
|
VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
|
||||||
|
STAMP=$(date -u +%Y%m%d-%H%M%S)
|
||||||
|
|
||||||
|
docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c \
|
||||||
|
"apk add -q sqlite && sqlite3 /data/bookmarks.db \"VACUUM INTO '/backup/bookmarks-$STAMP.db'\" &&
|
||||||
|
sqlite3 /backup/bookmarks-$STAMP.db 'PRAGMA integrity_check;'" &&
|
||||||
|
git pull --ff-only &&
|
||||||
|
$COMPOSE up -d --build &&
|
||||||
|
sleep 5 &&
|
||||||
|
curl -sf https://manga-api.violetcrown.my.id/healthz && echo " deploy ok"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `&&` chain is deliberate: if the backup or its integrity check fails,
|
||||||
|
nothing is pulled and nothing is rebuilt. 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 | Volume not attached — check `$COMPOSE config --volumes` and that you passed both `-f` files. 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:embed`ed. |
|
||||||
|
| Font answers `application/octet-stream` | Old binary — the `.woff2` MIME registration is in `web.go`. Rebuild. |
|
||||||
|
| Everyone logged out of the web UI | `API_TOKEN` or `WEB_PASSWORD` changed; sessions are derived from both. Expected, just log in again. |
|
||||||
|
| `compose` errors about `MANGA_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. |
|
||||||
|
| `apk add sqlite` fails (no network) | Use the cold-copy fallback in §1 — and copy `bookmarks.db-wal` too. |
|
||||||
|
| Reads work but every write fails after a restore | Restored file is root-owned; the container is uid 65532. `chown 65532:65532` it (§6). |
|
||||||
|
| Backup command: `unable to open database file` | Source volume mounted `:ro`. WAL needs to create `-shm`; mount it read-write (§1). |
|
||||||
|
|
||||||
|
Full first-time setup: `DEPLOY.md`. Config reference and endpoints: `README.md`.
|
||||||
|
UI conventions: `docs/design-system.md`.
|
||||||
Reference in New Issue
Block a user