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.
|