Deployment and operations

The compose stack, every environment variable by name, the metrics guard, Grafana's read-only role, and backups.

The web app is one Node process in front of a Postgres database. Migrations and the seeding of the curated designs happen at boot, so a fresh deployment needs no manual steps.

The supported way to run it is the compose stack at the repository root: the app, its database, and the monitoring that watches both.

Start the stack

bash
cp .env.example .env
docker compose up -d --build

Four secrets must be filled in before the stack will start. Generate each one:

bash
openssl rand -hex 24
VariableWhat it protects
POSTGRES_PASSWORDThe database
METRICS_TOKEN/api/metrics, and the token Prometheus presents to scrape it
GRAFANA_PASSWORDThe Grafana admin account
GRAFANA_DB_PASSWORDThe read-only database account Grafana queries with

First boot seeds the curated designs, which takes a minute or two. docker compose logs -f web shows the progress, and curl localhost:3000/api/health reports ok once the database is reachable.

What runs

ServicePortReachable from
web3000every interface
landing4000every interface
grafana3001localhost
prometheus9090localhost
db5432localhost
postgres-exporter9187the compose network only

Everything except the app binds to localhost, so on a server you reach Grafana through a tunnel rather than exposing it:

bash
ssh -L 3001:127.0.0.1:3001 you@your-server

The bind addresses are all settings, if you would rather put Grafana behind your own reverse proxy with authentication.

Environment

Names only. Every one of them is read from the environment; none has a value in the repository.

VariableRequiredMeaning
POSTGRES_PASSWORDyesDatabase password, used by the app, the exporter and backups
METRICS_TOKENyesBearer token for /api/metrics. Without a match the endpoint answers 404
GRAFANA_PASSWORDyesGrafana admin password
GRAFANA_DB_PASSWORDyesPassword for grafana_ro, the read-only database account
CANVAS_URLyesThe app's public origin. Used for the OAuth callback, redirects, cross-site checks, HSTS, and the landing page's buttons
GITHUB_CLIENT_IDfor sign-inFrom a GitHub OAuth app
GITHUB_CLIENT_SECRETfor sign-inThe same app's secret. Without these, reading works and creating is disabled
ZEEMA_ADMINSonceUsernames granted the admin role at their next sign-in
EVENT_SALTrecommendedSalt for the hashed network addresses in the event log
AUTO_SEEDno0 skips seeding the curated designs into an empty database
DATABASE_URLoutside composePostgres connection string. Unset locally to use the embedded database
ZEEMA_ROOTnoRepository root holding kinds/, components/, designs/

GitHub OAuth

GitHub, then Settings, Developer settings, OAuth Apps, New OAuth App.

  • Homepage URL: your CANVAS_URL.
  • Authorization callback URL: <CANVAS_URL>/api/auth/github/callback.

Only the read:user scope is requested. The access token reads the profile once and is not stored.

Monitoring

Prometheus scrapes two targets every fifteen seconds and keeps thirty days by default.

The app, at /api/metrics: response time and count for every request by route, method and status, taken from Node's own HTTP diagnostics channels, so pages are measured as accurately as API calls. Route labels collapse dynamic segments, so every design page is one series called /d/:owner/:slug. Alongside those: the Node process metrics, counters for design creates, saves, publishes, clones, deletes, exports and sign-ins, and gauges for accounts, designs by visibility, unpublished drafts, stored versions and active sessions.

Postgres, through postgres-exporter: connections, database size, transaction rates.

The metrics guard

/api/metrics is never published to the internet. In compose it is reachable on the internal network only, and where the port is exposed at all, METRICS_TOKEN gates it: a request without a matching Authorization: Bearer header — or ?token= — answers 404, not 401. An endpoint that admits it exists is an endpoint worth attacking.

Grafana's read-only role

Grafana also reads the application database directly, as grafana_ro, an account that can only SELECT. That is for the panels Prometheus cannot serve: a view count per design would be one time series per design, which is exactly the cardinality Prometheus is bad at, and the number already sits in a table.

The role is created when the database is first initialised. On a database that already exists, run it once by hand:

bash
docker compose exec -T db sh /docker-entrypoint-initdb.d/10-grafana-ro.sh

Everything Grafana shows is provisioned from the repository, and both the datasources and the dashboard are reapplied on restart. An edit made in the Grafana interface survives restarts until the repository file changes, at which point the file wins and the edit is gone. To keep a change, export the dashboard JSON over the file.

Backups

The database lives in a named volume. docker compose down and docker compose rm leave it alone; only docker compose down -v or an explicit volume removal destroys it.

A volume is not a backup. The backup script writes a compressed dump and prunes old ones:

bash
./ops/backup.sh

Nightly, from the repository root:

text
15 3 * * * cd /srv/zeema && ./ops/backup.sh >> /var/log/zeema-backup.log 2>&1

Copy the dumps off the machine, and restore one into a scratch database once, so you know the procedure works. Every design version is a row, so a restore brings back history as well as current state — and the curated reference designs are also files in the repository, so an empty database reseeds itself from them.

Updating

bash
git pull
docker compose up -d --build

Migrations run on the first request after boot. Take a backup first when a release adds one.

Without compose

bash
pnpm install
pnpm --filter @zeema/web build
cd apps/web && pnpm start

DATABASE_URL, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET and CANVAS_URL come from the environment. Put the process behind TLS: the session cookie is marked Secure in production, so plain HTTP will not keep anyone signed in.

One Node process uses about one core, whatever the machine has. To use more, run several containers behind a reverse proxy; they share the database and need no coordination.

Checks after a deployment

  • curl localhost:3000/api/health returns {"status":"ok"}.
  • GET /api/v1/designs returns the seeded list.
  • /signin shows "Continue with GitHub".
  • Prometheus, at /targets, shows both targets up.
  • The Grafana overview dashboard draws traffic.
On this page