192 lines
9.6 KiB
Markdown
192 lines
9.6 KiB
Markdown
|
|
# Operations — Queue North Website
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Status: Current
|
|||
|
|
Owner: _null
|
|||
|
|
Last reviewed: 2026-08-18
|
|||
|
|
Governs: Dockerfile, docker-compose.yml, scripts/status.sh,
|
|||
|
|
scripts/healthcheck.sh, scripts/backup.sh, scripts/restore-check.sh —
|
|||
|
|
what watches this in production, and what to do when it stops
|
|||
|
|
Review trigger: A new alert or a new place errors are sent; a change to the
|
|||
|
|
backup schedule or the restore procedure; any change to the
|
|||
|
|
deploy host, container or image; any incident whose first ten
|
|||
|
|
minutes were spent working out what to run
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Why this exists
|
|||
|
|
|
|||
|
|
Every other document here describes what the project *is*. This one describes
|
|||
|
|
what happens when it stops being that at three in the morning, and it is the
|
|||
|
|
only document written for somebody who is not thinking clearly.
|
|||
|
|
|
|||
|
|
The failure it prevents is specific: **the first ten minutes of an incident
|
|||
|
|
spent working out which host, which container, and which command**, while the
|
|||
|
|
thing everybody actually wants to know — is it down, or is it just unreachable
|
|||
|
|
from here — goes unanswered.
|
|||
|
|
|
|||
|
|
## What is deployed, and where
|
|||
|
|
|
|||
|
|
| | |
|
|||
|
|
| --- | --- |
|
|||
|
|
| Public origin | `https://qn.isnull.dev`, fronted by Cloudflare |
|
|||
|
|
| Host | **nebula** (`192.168.1.11`) — the same box as the Forgejo instance |
|
|||
|
|
| Container | `qn-website-dev` |
|
|||
|
|
| Image | `dream.scheller.ltd/null/queue-north-website:dev` |
|
|||
|
|
| Port | 3001 in the container; the reverse proxy in front owns the public one |
|
|||
|
|
| Database | `/app/db/queuenorth.db`, in the named volume `qn-website-dev_queuenorth-db` |
|
|||
|
|
| Logs volume | `qn-website-dev_queuenorth-logs` → `/app/logs` |
|
|||
|
|
| Runs as | UID 1001, non-root |
|
|||
|
|
|
|||
|
|
`queuenorth.com` also resolves and returns 200. **It is not this deployment** —
|
|||
|
|
it is the customer's own domain and is not served by this container. Do not
|
|||
|
|
diagnose against it.
|
|||
|
|
|
|||
|
|
## Where errors go
|
|||
|
|
|
|||
|
|
**Nowhere. This project has no error tracking.**
|
|||
|
|
|
|||
|
|
Said in one line rather than left blank, because a gap somebody chose is a
|
|||
|
|
different thing from a gap nobody noticed. There is no Sentry project, no DSN and
|
|||
|
|
no alert destination. An unhandled exception is logged to the container's stdout
|
|||
|
|
and takes the process down: `server/index.js` handles `uncaughtException` and
|
|||
|
|
`unhandledRejection` by logging and exiting 1, and Docker's `restart:
|
|||
|
|
unless-stopped` brings it back. **That means a crash loop looks like a running
|
|||
|
|
service to anything that only reads a state.** `scripts/status.sh` reports the
|
|||
|
|
restart count for exactly this reason.
|
|||
|
|
|
|||
|
|
**A liveness tick is not error tracking.** `scripts/healthcheck.sh` answers *is
|
|||
|
|
it up*. Nothing here answers *is it working* — a server returning 500 to every
|
|||
|
|
form submission is up, healthy, and losing every lead.
|
|||
|
|
|
|||
|
|
## What alerts, and to whom
|
|||
|
|
|
|||
|
|
| Signal | Where it goes | Who acts on it |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| Container `HEALTHCHECK` fails 3× at 30 s | Docker marks the container unhealthy on nebula | **Nobody, automatically.** It is visible to anyone who looks and notifies no one |
|
|||
|
|
| `scripts/healthcheck.sh` | whoever runs it | not scheduled |
|
|||
|
|
| Everything else | — | — |
|
|||
|
|
|
|||
|
|
**This table is nearly empty and that is the honest state.** Nothing on this
|
|||
|
|
project pages anybody. The site could be down for a day and the first report
|
|||
|
|
would come from the customer.
|
|||
|
|
|
|||
|
|
Two things would change that and neither is filed as work yet: scheduling
|
|||
|
|
`healthcheck.sh` somewhere that can shout, and giving the process somewhere to
|
|||
|
|
send an exception. Recorded here rather than invented into the tracker.
|
|||
|
|
|
|||
|
|
## Backups
|
|||
|
|
|
|||
|
|
| | |
|
|||
|
|
| --- | --- |
|
|||
|
|
| Schedule | **None yet.** `scripts/backup.sh` works and nothing runs it on a timer |
|
|||
|
|
| What is captured | `/app/db/queuenorth.db` — the `leads` and `support_requests` tables. Deliberately not captured: the logs volume, and the environment, which holds the secrets |
|
|||
|
|
| Where it lands | `$HOME/backups/queue-north-website` on the operator's machine, mode 700. **Still one machine** — see below |
|
|||
|
|
| Retention | `BACKUP_KEEP`, default 7 |
|
|||
|
|
| **Last verified restore** | **2026-08-18, by `_null` — 2 tables, 3 rows, restored in under 1 s** |
|
|||
|
|
|
|||
|
|
`scripts/backup.sh` proves a dump is readable before trusting it — it runs
|
|||
|
|
better-sqlite3's online `.backup()` inside the container, copies the result out,
|
|||
|
|
and refuses the run unless `PRAGMA integrity_check` returns `ok` and the table
|
|||
|
|
count is plausible, only then renaming it into place. `scripts/restore-check.sh`
|
|||
|
|
proves it can be restored, which is a different claim: a file that parses is not
|
|||
|
|
a database you can get back.
|
|||
|
|
|
|||
|
|
**A backup nobody has restored is a guess.** The date above is the only line in
|
|||
|
|
this table that says otherwise, and as of 2026-08-18 it says something: a
|
|||
|
|
snapshot was taken from the running container, replayed into a scratch database
|
|||
|
|
from SQL, and counted. It came back.
|
|||
|
|
|
|||
|
|
**Two things that are still true anyway**, and both are tracked in `Batch 15`:
|
|||
|
|
|
|||
|
|
1. **Nothing runs on a schedule.** One backup taken by hand is not a backup
|
|||
|
|
regime. Until a timer exists, the newest dump is as old as the last time
|
|||
|
|
somebody remembered.
|
|||
|
|
2. **The dump lives on one machine, beside nothing.** `backup.sh` says this
|
|||
|
|
itself on every run: *a backup that only exists beside the database it came
|
|||
|
|
from does not survive the disk, the host or the account.* It is currently on
|
|||
|
|
the operator's workstation and nowhere else.
|
|||
|
|
|
|||
|
|
The exposure is worth stating plainly: `/app/db/queuenorth.db` is the only live
|
|||
|
|
copy of every lead and support request the site has ever taken, it lives in one
|
|||
|
|
Docker volume on one host, and it took writes today.
|
|||
|
|
|
|||
|
|
**A number worth knowing before you need it:** the restore took under a second,
|
|||
|
|
because the database is 28 KB and holds three leads. That will stay true for a
|
|||
|
|
long time at this volume — this is a contact form, not a transactional system —
|
|||
|
|
so during an incident, restoring is cheap and there is no reason to hesitate over
|
|||
|
|
it.
|
|||
|
|
|
|||
|
|
`RESTORE_MIN_ROWS` is currently 0, which means the check cannot yet catch the
|
|||
|
|
snapshot-of-an-empty-volume case. Set it to something below the real lead count
|
|||
|
|
once that count is meaningful, and it starts catching the one failure a
|
|||
|
|
structural check never can.
|
|||
|
|
|
|||
|
|
## Rate limits and cost ceilings
|
|||
|
|
|
|||
|
|
*(precautionary — no incident here has yet come from this.)*
|
|||
|
|
|
|||
|
|
| Endpoint or job | What it costs per call | What bounds it |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| `POST /api/leads` | one SQLite insert, one reCAPTCHA verify, one Zoho form post | `RATE_LIMIT_PER_MINUTE`, default 5, per IP across all of `/api`. Body capped at 1 MB, request at 30 s |
|
|||
|
|
| `POST /api/support` | the same, plus a Zoho Case when enabled | same limiter |
|
|||
|
|
| Google reCAPTCHA | free at this volume | the same limiter, upstream of it |
|
|||
|
|
| Zoho CRM | free at this volume; the API path has per-org daily credits | fire-and-forget, so exhausting them degrades CRM sync and never the site |
|
|||
|
|
|
|||
|
|
**Where does the spend alert go, and at what number?** Nowhere, and there is no
|
|||
|
|
number. Nothing here bills per call at this volume, which is why that is
|
|||
|
|
tolerable rather than an oversight — but if an LLM, a mail sender or a paid API
|
|||
|
|
is ever added, this table is the first thing that has to change.
|
|||
|
|
|
|||
|
|
## It is down — what now
|
|||
|
|
|
|||
|
|
In this order. Each step is a command that answers one question, and none of them
|
|||
|
|
change anything.
|
|||
|
|
|
|||
|
|
1. **Is it actually down, or unreachable from here?**
|
|||
|
|
`bash scripts/healthcheck.sh` from anywhere, then
|
|||
|
|
`bash scripts/status.sh`. The second distinguishes those two and reports the
|
|||
|
|
restart count. **A climbing restart count with a short uptime is a crash loop**,
|
|||
|
|
not a healthy service, and reads as "running" in every tool that shows only a
|
|||
|
|
state.
|
|||
|
|
2. **What is it saying?** `bash scripts/status.sh --logs 200`.
|
|||
|
|
3. **Is the deployed version the one you think?** `bash scripts/status.sh`
|
|||
|
|
prints the image digest. **`--deployed-version` cannot answer here** — the
|
|||
|
|
image carries no `org.opencontainers.image.version` label, so the script
|
|||
|
|
correctly reports the version as unknown rather than guessing. Compare the
|
|||
|
|
**digest** against the registry instead:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
bash scripts/status.sh | grep digest
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Adding that label at build time would make this step a one-liner and is worth
|
|||
|
|
doing next time the Dockerfile is touched.
|
|||
|
|
4. **Is it the database?** `/api/health` returns 503 with `db: error` when the
|
|||
|
|
`SELECT 1` fails. That is a real answer and not an outage of the whole site:
|
|||
|
|
the marketing pages keep serving while every form submission is failing.
|
|||
|
|
5. **Did something change recently?** The tracker and `git log` — a deploy, a
|
|||
|
|
config edit, a Cloudflare rule.
|
|||
|
|
6. **Is it Cloudflare rather than us?** Compare a request to the origin on nebula
|
|||
|
|
against one to `qn.isnull.dev`. Cloudflare fronts everything here, including
|
|||
|
|
the Forgejo instance on the same host.
|
|||
|
|
|
|||
|
|
### Rolling forward or back
|
|||
|
|
|
|||
|
|
**This is the gap in this runbook and it is named rather than papered over.**
|
|||
|
|
|
|||
|
|
Publishing is `npm run docker:push`, which builds and pushes
|
|||
|
|
`queue-north-website:dev` to the Forgejo registry. How nebula then *moves* to the
|
|||
|
|
new image — a compose file on the host, a Portainer stack, a watchtower, a manual
|
|||
|
|
`docker pull && docker compose up -d` — is not written down anywhere in this
|
|||
|
|
repository, and was not recoverable from it on 2026-08-18.
|
|||
|
|
|
|||
|
|
Until somebody writes it here, a rollback is: find the previous image digest in
|
|||
|
|
the registry, and do on nebula whatever it is that normally happens. That is not
|
|||
|
|
a procedure. The template's `deploy.py` was deliberately **not** adopted for this
|
|||
|
|
reason — a deploy script pointed at a path nobody has confirmed is worse than no
|
|||
|
|
deploy script.
|
|||
|
|
|
|||
|
|
**Write the incident down afterwards**, in `docs/history/DEVELOPMENT_LOG.md` with
|
|||
|
|
the date, and file what broke as an issue with a severity label. An incident
|
|||
|
|
nobody recorded happens again with the same surprise.
|