Skip to content

Phase 9 — First boot and health checks

This is the moment of truth. With DNS, certificates, the database, the reverse proxy, SteVe, and the email worker all wired up, you bring the Polaris Express web stack online for the first time and verify that every container reports healthy.

This phase does not put load on the system. It confirms that the plumbing works. The next phase covers creating the first admin account and exercising a real charging session.

  • Phases 1–8 complete. In particular:

    • web/.env exists and is populated for your domain.
    • Postgres credentials in web/.env match what you intend to use.
    • DNS for your app hostname resolves to this host.
    • TLS termination (Caddy, Traefik, or your fronting proxy) is configured and pointed at localhost:8000.
    • SteVe is reachable from the app container on its OCPP-J port.
    • The email worker is deployed (or running under wrangler dev) and its URL is in CF_EMAIL_WORKER_URL .
  • The polaris-express repo is checked out at the commit you intend to deploy, with the web/ submodule initialised:

    Terminal window
    git submodule update --init --recursive
  • Docker Engine 24+ and the Compose v2 plugin (docker compose, not docker-compose).

  1. Verify the compose file resolves

    From the repo root, ask Compose to parse the stack without starting anything. This catches missing env vars and bad references before containers spin up.

    Terminal window
    docker compose config --quiet

    If this prints nothing, the file is valid. If it complains about an undefined variable, fix web/.env before continuing — Compose will not fall back to defaults for required values.

  2. Build the web image

    The app, migrate, and sync services all share the same image built from web/Dockerfile. Build it once explicitly so the first up doesn’t conflate build errors with runtime errors.

    Terminal window
    docker compose build app

    First builds take 3–6 minutes depending on the host.

  3. Start Postgres alone and wait for it to be healthy

    Postgres is the foundation. Bring it up first and wait for its healthcheck to pass before letting the migrate job touch it.

    Terminal window
    docker compose up -d postgres
    docker compose ps postgres

    Re-run docker compose ps postgres until the STATUS column shows healthy (not just running). This usually takes 10–20 seconds.

    The healthcheck runs pg_isready -U ocpp_user -d ocpp_billing. If your POSTGRES_USER or POSTGRES_DB in web/.env differ from those defaults, the healthcheck will never pass and the dependent services will refuse to start.

  4. Run the migration job

    The migrate service runs deno task db:migrate once and exits. It depends on postgres being healthy, so Compose will block until that condition is met.

    Terminal window
    docker compose up migrate

    Watch the output. Expected outcome:

    • Migration messages stream past.
    • The container exits with code 0.
    • Compose returns you to the shell.

    If you see a non-zero exit, do not proceed. Read the logs, fix the underlying problem (almost always a bad DATABASE_URL, a Postgres permission issue, or a schema collision with a pre-existing database), and re-run this step. The migrate job is idempotent — re-running on a partially-migrated database is safe.

  5. Start the app and sync worker

    With the schema in place, bring up the long-running services.

    Terminal window
    docker compose up -d app sync

    Both should reach running within ~10 seconds. Tail logs to watch the boot sequence:

    Terminal window
    docker compose logs -f app sync

    You’re looking for, in the app logs:

    • A Listening on http://0.0.0.0:8000 (or equivalent) line.
    • No repeated stack traces.

    And in the sync logs:

    • A “sync run started” or scheduler line.
    • No connection errors against Postgres or SteVe.

    Press Ctrl-C to detach from logs — the containers keep running.

  6. Confirm all four containers are up

    Terminal window
    docker compose ps

    You should see:

    ServiceExpected state
    postgresrunning (healthy)
    migrateexited (0)
    apprunning
    syncrunning

    migrate having exited is correct — it’s a one-shot job. If it shows restarting, something is wrong; see If something goes wrong.

Every service in the compose stack loads web/.env via env_file. There is no per-service env block — all variables live in one file.

NameDefaultRequiredSource
POSTGRES_USER ocpp_useryesweb/.env
POSTGRES_PASSWORD (none)yesweb/.env
POSTGRES_DB ocpp_billingyesweb/.env
DATABASE_URL (none)yesweb/.env
CF_EMAIL_WORKER_URL (none)yesweb/.env

Run these from the host. They confirm the stack is reachable and internally consistent.

1. Direct app reachability.

Terminal window
curl -fsS -o /dev/null -w "%{http_code}\n" http://localhost:8000/

Expect 200 or a 3xx redirect to the login page. A 502 or connection refused means the app container isn’t actually listening.

2. Through your reverse proxy.

Terminal window
curl -fsS -o /dev/null -w "%{http_code}\n" https://app.example.com/

Substitute your real hostname. Expect the same as above. If this fails but step 1 succeeded, the problem is in your TLS-terminating proxy, not Polaris Express.

3. Database schema present.

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

Expect a list of tables. An empty result means migrations didn’t run.

4. Sync worker is alive.

Terminal window
docker compose logs --tail 50 sync

You should see periodic activity. A worker that logged once at startup and has been silent for many minutes is suspicious — check that it can reach SteVe.

migrate keeps restarting. The migrate service has restart: "no", so if you see it restarting, you’ve added a restart policy in an override file. Remove it — migrate is meant to run once and exit.

app exits immediately with a Postgres connection error. DATABASE_URL in web/.env does not match the postgres service. Inside the compose network the host is postgres, not localhost or 127.0.0.1. A working value looks like postgresql://ocpp_user:PASSWORD@postgres:5432/ocpp_billing.

app boots but / returns 500. Usually a missing secret. Check docker compose logs app for the first stack trace after startup — it almost always names the missing variable.

Reverse proxy returns 502 but curl http://localhost:8000/ works. Your proxy is targeting the wrong upstream. Confirm it points at localhost:8000 (or the Docker bridge IP if the proxy itself runs in a container that isn’t on the host network).

Healthcheck on postgres never goes green. The pg_isready command inside the container is checking for ocpp_user / ocpp_billing specifically. If you changed those names in web/.env, either change them back or override the healthcheck in compose.override.yml.

With the stack healthy, you’re ready to create the first admin account and run a smoke-test charging session.