Phase 5 — Bring up the Fresh web app
This phase builds the web/ submodule into a container, brings up
Postgres, runs migrations, and starts the long-running app and sync
worker. After this phase you have a running Polaris Express stack that
serves both the customer portal and the admin console, even if it
isn’t yet wired to a real SteVe or Lago. Those integrations are
verified in later phases.
Prerequisites
Section titled “Prerequisites”- Phases 1–4 complete: host provisioned, Docker installed, DNS
records for
CUSTOMER_BASE_URLandADMIN_BASE_URLpointing at this host, and a reverse proxy ready to terminate TLS on:8000. - SteVe is reachable from this host (Phase 3) — you have its base URL, API username, and API key.
- Lago is reachable (Phase 4) — you have an API key and a billable metric code.
- A populated
web/.env(see the env-var table below). The Compose stack reads this single file across all four services. - The
web/submodule is checked out at the commit you intend to ship. If you cloned with--recurse-submodulesyou already have it; otherwise rungit submodule update --init --recursive.
-
Create the env file
Copy the example and edit it. Every service in the Compose stack loads this same file via
env_file: web/.env.Terminal window cp web/.env.example web/.env$EDITOR web/.envAt minimum, set:
DATABASE_URL,POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB— the URL’s user/password/db must match the threePOSTGRES_*values or the Postgres container and the app will disagree.AUTH_SECRET— 32+ random bytes. Generate withopenssl rand -hex 32.AUTH_URL,ADMIN_BASE_URL,CUSTOMER_BASE_URL,COOKIE_DOMAIN— must reflect your real hostnames.COOKIE_DOMAINneeds the leading dot so the session cookie is valid on both subdomains.STEVE_BASE_URL,STEVE_API_USERNAME,STEVE_API_KEY— from Phase 3.LAGO_API_URL,LAGO_API_KEY,LAGO_METRIC_CODE,LAGO_DASHBOARD_URL— from Phase 4.
-
Build the web image
The
app,migrate, andsyncservices share the same image, built fromweb/Dockerfile. Building once up front avoids three concurrent builds when you bring the stack up.Terminal window docker compose build appThis pulls
denoland/deno:2.6.3, runsdeno install, thendeno task build. Expect a few minutes on the first build. -
Start Postgres and run migrations
Bring up Postgres alone first so you can confirm it’s healthy before the app starts touching it.
Terminal window docker compose up -d postgresdocker compose ps postgresWait until the
STATUScolumn readshealthy(the healthcheck runspg_isreadyevery 10 seconds). Then run the one-shot migrate service:Terminal window docker compose run --rm migrateThis executes
deno task db:migrateagainst the database and exits. If it fails, fix the error (almost always aDATABASE_URLmismatch or a missing role) before continuing — the app service will refuse to start untilmigratereportsservice_completed_successfully. -
Start the app and the sync worker
Terminal window docker compose up -d app syncdocker compose psYou should see four services running:
postgres,app,sync, and the completed (exited 0)migrate. The app binds to:8000on the host; point your reverse proxy at that port.The
appcontainer mounts/var/run/docker.sockso the in-process tag-detection feature can stream logs from the SteVe container by name. If you do not run SteVe on the same Docker host, leaveSTEVE_CONTAINER_NAMEset but expect the log-stream feature to be inert — it does not block boot. -
Tail the logs
Terminal window docker compose logs -f app syncWatch for:
app— a line indicating the HTTP server is listening on:8000.sync— a line indicating the scheduler has started. With the default adaptive cadence, the first tick may not fire for up to 15 minutes. SetSYNC_ON_STARTUP=falsetotruetemporarily if you want an immediate run for verification.
Configure environment variables
Section titled “Configure environment variables”This phase reads the full web/.env. The table below covers the
variables that gate boot or that you must set correctly for this
phase to succeed. Variables specific to email, push, or OIDC are
covered in their own phases.
| Variable | Default | Required | Notes |
|---|---|---|---|
DATABASE_URL | postgresql://user:password@localhost:5432/ocpp_billing | yes | Must point at the in-stack postgres service hostname (postgres) and match the POSTGRES_* credentials. |
POSTGRES_DB | ocpp_billing | yes | Consumed by the postgres image on first boot. |
POSTGRES_USER | ocpp_user | yes | Must match the user in DATABASE_URL and the healthcheck (pg_isready -U ocpp_user). |
POSTGRES_PASSWORD | ocpp_password | yes | Change before going to production. |
PORT | 8000 | no | Container listens here; Compose publishes 8000:8000. |
DENO_ENV | development | yes | Set to production for deployed environments. |
DEBUG_LEVEL | ERROR | no | INFO is recommended while bringing a new site online. |
AUTH_SECRET | (none) | yes | 32+ chars. Rotating invalidates all sessions. |
AUTH_URL | http://localhost:8000 | yes | The customer-facing public URL. Used by BetterAuth for redirect validation. |
ADMIN_BASE_URL | https://manage.example.com | yes | The admin console hostname. |
CUSTOMER_BASE_URL | https://example.com | yes | The customer portal hostname. |
COOKIE_DOMAIN | .example.com | yes | Leading dot — must cover both subdomains. |
OPERATOR_CONTACT_EMAIL | [email protected] | yes | Surfaced on error pages and inactive-customer screens. |
STEVE_BASE_URL | http://localhost:8080/steve | yes | API calls append /api; dashboard links use the base. |
STEVE_API_USERNAME | admin | yes | Matches SteVe’s auth.user. |
STEVE_API_KEY | (none) | yes | Matches SteVe’s webapi.value. |
LAGO_API_URL | https://api.getlago.com | yes | API base; /api/v1 is appended. |
LAGO_API_KEY | (none) | yes | From the Lago admin UI. |
LAGO_DASHBOARD_URL | https://app.getlago.com | yes | Used only for external links. |
LAGO_METRIC_CODE | ev_charging_kwh | yes | Must match the Lago metric created in Phase 4. |
SYNC_ON_STARTUP | false | no | Set to true once when verifying; revert after. |
SYNC_LOOKBACK_MINUTES | 10080 | no | 7 days. The example file’s literal default; the in-code fallback referenced in the comment is 24h. |
SYNC_CRON_SCHEDULE | (unset) | no | When unset, the adaptive scheduler runs. Set only as an escape hatch. |
SYNC_DORMANT_THRESHOLD_DAYS | 30 | no | Days of inactivity before demotion to dormant. |
SYNC_IDLE_HYSTERESIS_TICKS | 2 | no | Idle evaluations required before demoting a tier. |
DOCKER_SOCKET_PATH | /var/run/docker.sock | no | Mounted into the app container by Compose. |
STEVE_CONTAINER_NAME | steve | no | Name of the SteVe container on the same Docker host. |
Verify
Section titled “Verify”Run these from the host (or anywhere the app is reachable). Replace
https://example.com with your CUSTOMER_BASE_URL.
HTTP reachability:
curl -sS -o /dev/null -w "%{http_code}\n" https://example.com/curl -sS -o /dev/null -w "%{http_code}\n" https://manage.example.com/Both should return 200 (or 302 to a login page). A 502 or 504
means your reverse proxy can’t reach the app container on :8000.
Database connectivity:
docker compose exec postgres psql -U ocpp_user -d ocpp_billing -c '\dt' | headYou should see a non-empty list of tables. An empty list means
migrations did not run — re-run docker compose run --rm migrate
and inspect its output.
Sync worker heartbeat:
docker compose logs --tail=50 syncLook for either a scheduled-tick log line or, if you set
SYNC_ON_STARTUP=true, a completed first run. A successful sync
run will hit SteVe’s API and (optionally) Lago.
Admin console renders:
Open https://manage.example.com/ in a browser. You should see the
login screen. If ADMIN_OIDC_ISSUER is unset, this will be the
email/password form.
If something goes wrong
Section titled “If something goes wrong”migrate exits non-zero with password authentication failed.
DATABASE_URL and the POSTGRES_* triple disagree. The Postgres
container only initializes credentials on first boot — if you’ve
already started it once with the wrong values, stop it, remove the
volume, and start over:
docker compose down -vdocker compose up -d postgresdocker compose run --rm migrateapp keeps restarting with AUTH_SECRET must be set. Set it to
a 32+ character random string in web/.env and docker compose up -d app.
Customers get redirect loops between portal and admin.
COOKIE_DOMAIN is wrong. It must be the parent domain with a leading
dot (.example.com), and both CUSTOMER_BASE_URL and
ADMIN_BASE_URL must be subdomains of it.
app logs show ECONNREFUSED against SteVe. STEVE_BASE_URL
isn’t reachable from inside the app container. If SteVe runs on the
same host, use the Docker network hostname (e.g. http://steve:8080/steve)
or the host IP, not localhost.
Sync worker never ticks. Adaptive cadence may have placed all
chargers in the Dormant tier (1-week interval). Set
SYNC_ON_STARTUP=true for one boot to confirm the worker itself is
healthy, then revert.
Tag-detection log streaming fails with permission denied on the
Docker socket. The app user inside the container needs access to
/var/run/docker.sock. On hosts with strict socket permissions,
either add the deno UID to the docker group on the host or accept
that tag-detection from container logs will be disabled.
Next phase
Section titled “Next phase”Phase 6 deploys the Cloudflare email worker
and wires CF_EMAIL_WORKER_URL so that magic link
emails leave the host instead of being logged to stdout.