Skip to content

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.

  • Phases 1–4 complete: host provisioned, Docker installed, DNS records for CUSTOMER_BASE_URL and ADMIN_BASE_URL pointing 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-submodules you already have it; otherwise run git submodule update --init --recursive.
  1. 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/.env

    At minimum, set:

    • DATABASE_URL, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB — the URL’s user/password/db must match the three POSTGRES_* values or the Postgres container and the app will disagree.
    • AUTH_SECRET — 32+ random bytes. Generate with openssl rand -hex 32.
    • AUTH_URL, ADMIN_BASE_URL, CUSTOMER_BASE_URL, COOKIE_DOMAIN — must reflect your real hostnames. COOKIE_DOMAIN needs 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.
  2. Build the web image

    The app, migrate, and sync services share the same image, built from web/Dockerfile. Building once up front avoids three concurrent builds when you bring the stack up.

    Terminal window
    docker compose build app

    This pulls denoland/deno:2.6.3, runs deno install, then deno task build. Expect a few minutes on the first build.

  3. 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 postgres
    docker compose ps postgres

    Wait until the STATUS column reads healthy (the healthcheck runs pg_isready every 10 seconds). Then run the one-shot migrate service:

    Terminal window
    docker compose run --rm migrate

    This executes deno task db:migrate against the database and exits. If it fails, fix the error (almost always a DATABASE_URL mismatch or a missing role) before continuing — the app service will refuse to start until migrate reports service_completed_successfully.

  4. Start the app and the sync worker

    Terminal window
    docker compose up -d app sync
    docker compose ps

    You should see four services running: postgres, app, sync, and the completed (exited 0) migrate. The app binds to :8000 on the host; point your reverse proxy at that port.

    The app container mounts /var/run/docker.sock so 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, leave STEVE_CONTAINER_NAME set but expect the log-stream feature to be inert — it does not block boot.

  5. Tail the logs

    Terminal window
    docker compose logs -f app sync

    Watch 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. Set SYNC_ON_STARTUP = false to true temporarily if you want an immediate run for verification.

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.

VariableDefaultRequiredNotes
DATABASE_URLpostgresql://user:password@localhost:5432/ocpp_billingyesMust point at the in-stack postgres service hostname (postgres) and match the POSTGRES_* credentials.
POSTGRES_DBocpp_billingyesConsumed by the postgres image on first boot.
POSTGRES_USERocpp_useryesMust match the user in DATABASE_URL and the healthcheck (pg_isready -U ocpp_user).
POSTGRES_PASSWORDocpp_passwordyesChange before going to production.
PORT8000noContainer listens here; Compose publishes 8000:8000.
DENO_ENVdevelopmentyesSet to production for deployed environments.
DEBUG_LEVELERRORnoINFO is recommended while bringing a new site online.
AUTH_SECRET(none)yes32+ chars. Rotating invalidates all sessions.
AUTH_URLhttp://localhost:8000yesThe customer-facing public URL. Used by BetterAuth for redirect validation.
ADMIN_BASE_URLhttps://manage.example.comyesThe admin console hostname.
CUSTOMER_BASE_URLhttps://example.comyesThe customer portal hostname.
COOKIE_DOMAIN.example.comyesLeading dot — must cover both subdomains.
OPERATOR_CONTACT_EMAIL[email protected]yesSurfaced on error pages and inactive-customer screens.
STEVE_BASE_URLhttp://localhost:8080/steveyesAPI calls append /api; dashboard links use the base.
STEVE_API_USERNAMEadminyesMatches SteVe’s auth.user.
STEVE_API_KEY(none)yesMatches SteVe’s webapi.value.
LAGO_API_URLhttps://api.getlago.comyesAPI base; /api/v1 is appended.
LAGO_API_KEY(none)yesFrom the Lago admin UI.
LAGO_DASHBOARD_URLhttps://app.getlago.comyesUsed only for external links.
LAGO_METRIC_CODEev_charging_kwhyesMust match the Lago metric created in Phase 4.
SYNC_ON_STARTUPfalsenoSet to true once when verifying; revert after.
SYNC_LOOKBACK_MINUTES10080no7 days. The example file’s literal default; the in-code fallback referenced in the comment is 24h.
SYNC_CRON_SCHEDULE(unset)noWhen unset, the adaptive scheduler runs. Set only as an escape hatch.
SYNC_DORMANT_THRESHOLD_DAYS30noDays of inactivity before demotion to dormant.
SYNC_IDLE_HYSTERESIS_TICKS2noIdle evaluations required before demoting a tier.
DOCKER_SOCKET_PATH/var/run/docker.socknoMounted into the app container by Compose.
STEVE_CONTAINER_NAMEstevenoName of the SteVe container on the same Docker host.

Run these from the host (or anywhere the app is reachable). Replace https://example.com with your CUSTOMER_BASE_URL.

HTTP reachability:

Terminal window
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:

Terminal window
docker compose exec postgres psql -U ocpp_user -d ocpp_billing -c '\dt' | head

You 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:

Terminal window
docker compose logs --tail=50 sync

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

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:

Terminal window
docker compose down -v
docker compose up -d postgres
docker compose run --rm migrate

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

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.