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.
Prerequisites
Section titled “Prerequisites”-
Phases 1–8 complete. In particular:
web/.envexists and is populated for your domain.- Postgres credentials in
web/.envmatch 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 inCF_EMAIL_WORKER_URL.
-
The
polaris-expressrepo is checked out at the commit you intend to deploy, with theweb/submodule initialised:Terminal window git submodule update --init --recursive -
Docker Engine 24+ and the Compose v2 plugin (
docker compose, notdocker-compose).
-
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 --quietIf this prints nothing, the file is valid. If it complains about an undefined variable, fix
web/.envbefore continuing — Compose will not fall back to defaults for required values. -
Build the web image
The
app,migrate, andsyncservices all share the same image built fromweb/Dockerfile. Build it once explicitly so the firstupdoesn’t conflate build errors with runtime errors.Terminal window docker compose build appFirst builds take 3–6 minutes depending on the host.
-
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 postgresdocker compose ps postgresRe-run
docker compose ps postgresuntil theSTATUScolumn showshealthy(not justrunning). This usually takes 10–20 seconds.The healthcheck runs
pg_isready -U ocpp_user -d ocpp_billing. If yourPOSTGRES_USERorPOSTGRES_DBinweb/.envdiffer from those defaults, the healthcheck will never pass and the dependent services will refuse to start. -
Run the migration job
The
migrateservice runsdeno task db:migrateonce and exits. It depends onpostgresbeing healthy, so Compose will block until that condition is met.Terminal window docker compose up migrateWatch 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. -
Start the app and sync worker
With the schema in place, bring up the long-running services.
Terminal window docker compose up -d app syncBoth should reach
runningwithin ~10 seconds. Tail logs to watch the boot sequence:Terminal window docker compose logs -f app syncYou’re looking for, in the
applogs:- A
Listening on http://0.0.0.0:8000(or equivalent) line. - No repeated stack traces.
And in the
synclogs:- A “sync run started” or scheduler line.
- No connection errors against Postgres or SteVe.
Press
Ctrl-Cto detach from logs — the containers keep running. - A
-
Confirm all four containers are up
Terminal window docker compose psYou should see:
Service Expected state postgresrunning(healthy)migrateexited (0)apprunningsyncrunningmigratehaving exited is correct — it’s a one-shot job. If it showsrestarting, something is wrong; see If something goes wrong.
Configure environment variables
Section titled “Configure environment variables”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.
| Name | Default | Required | Source |
|---|---|---|---|
POSTGRES_USER | ocpp_user | yes | web/.env |
POSTGRES_PASSWORD | (none) | yes | web/.env |
POSTGRES_DB | ocpp_billing | yes | web/.env |
DATABASE_URL | (none) | yes | web/.env |
CF_EMAIL_WORKER_URL | (none) | yes | web/.env |
Verify
Section titled “Verify”Run these from the host. They confirm the stack is reachable and internally consistent.
1. Direct app reachability.
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.
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.
docker compose exec postgres \ psql -U ocpp_user -d ocpp_billing -c "\dt" | head -20Expect a list of tables. An empty result means migrations didn’t run.
4. Sync worker is alive.
docker compose logs --tail 50 syncYou 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.
If something goes wrong
Section titled “If something goes wrong”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.
Next phase
Section titled “Next phase”With the stack healthy, you’re ready to create the first admin account and run a smoke-test charging session.