car-tracker/docs/DEPLOYMENT.md
Antoine Vandeplanque b06bcb76a8
All checks were successful
CI / Lint, PostgreSQL tests, and package (push) Successful in 13s
CI / Build and publish tested image (push) Successful in 1m16s
randomize pauses between collection requests
2026-10-13 14:33:19 +02:00

12 KiB
Raw Permalink Blame History

Manual Homelab Deployment

Scope

The next milestone is one manual collection -> raw files -> PostgreSQL import job on the homelab. Development stays on the laptop; build and run from a repository checkout on the existing Debian VM, alongside Gitea, its runner, and PostgreSQL 17. Use the existing database and role, currently both car_tracker. Do not provision another production PostgreSQL server or recreate those objects.

ops/app/compose.yaml is the one-shot application stack, separate from ops/ci-runner. This section describes direct manual operation, with no scheduling or restart loop. For the optional Gitea button → restricted SSH → host-run flow, see SSH_RUN.md. Migrations remain manual in both modes. Run commands from the homelab checkout's root with Docker Engine and Compose available. Review the revision and searches.toml; do not run these in CI. After these changes are published, update the homelab checkout before building.

Private Runtime Configuration

Use an external, nonsecret Compose env file, for example /srv/car-tracker/app.env. These absolute paths are examples, not secrets. Inspect existing files first: never overwrite an existing env file or .pgpass. Use ops/app/.env.example as the reference when creating a new file locally:

APP_UID=1000
APP_GID=1000
RAW_DATA_DIR=/srv/car-tracker/raw
DB_CONFIG_DIR=/srv/car-tracker/db
PGHOST=postgres.your-lan.example
PGPORT=5432
PGDATABASE=car_tracker
PGUSER=car_tracker
PGSSLMODE=require
APP_SUBNET=172.30.17.0/28

Replace the hostname with the existing VM's reachable LAN/VPN hostname and choose the numeric APP_UID/APP_GID that will own the mounted files. Keep the application user non-root. Both bind directories must already exist; Compose refuses to create missing source directories. After inspecting /srv and any existing application directories, create only missing directories and apply permissions deliberately:

sudo install -d -m 0700 -o 1000 -g 1000 /srv/car-tracker/raw /srv/car-tracker/db

Use your chosen UID/GID instead of 1000 in all ownership commands. Both RAW_DATA_DIR and DB_CONFIG_DIR must have mode 0700 and the correct ownership. The raw directory must be writable by the container user; the DB directory must be traversable by that user even though its container mount is read-only.

Create or carefully edit /srv/car-tracker/db/pgpass locally, using a private editor/session. Its matching entry has this form (do not use the placeholder):

postgres.your-lan.example:5432:car_tracker:car_tracker:<password entered locally>

Escape : and \ in the password according to libpq password-file rules. Do not put passwords in commands, the env file, Git, logs, or chat. Do not send tokens or credentials to anyone to complete this setup. Preserve existing password files and entries rather than replacing them blindly. Protect the dedicated file:

sudo chown 1000:1000 /srv/car-tracker/db/pgpass
sudo chmod 0600 /srv/car-tracker/db/pgpass

The password is supplied only through /run/db/pgpass, mounted from DB_CONFIG_DIR. Incorrect ownership prevents reads; libpq ignores a password file with overly permissive permissions. Keep this directory dedicated to app DB files, not unrelated credentials.

Compose interpolates the external env file but passes only the runtime whitelist: PGHOST, PGPORT, PGDATABASE, PGUSER, and PGSSLMODE, plus fixed PGPASSFILE=/run/db/pgpass and PGSSLROOTCERT=/run/db/root.crt. It does not pass through DATABASE_URL, PGPASSWORD, or arbitrary host variables. Shell variables can override Compose env-file interpolation; unset stale matching variables before using this configuration. Always specify --env-file explicitly.

TLS And Network Access

PGSSLMODE defaults to require: require encryption, but do not treat this as server identity verification. Prefer verify-full with the trusted CA certificate at /srv/car-tracker/db/root.crt, readable by the application user. The existing directory mount exposes it as /run/db/root.crt; PGHOST must match the server certificate. Configure PostgreSQL server TLS before running. Never fix production connectivity by disabling TLS or adding trust authentication.

localhost inside the container is not the VM's PostgreSQL server. Prefer the VM's LAN/VPN hostname. Compose also provides host.docker.internal through extra_hosts: host-gateway, but PostgreSQL must listen on that gateway address if you use it. A listener only on loopback or the LAN address does not necessarily accept gateway connections. Certificate hostname matching still applies.

The app has a dedicated bridge subnet, default 172.30.17.0/28. Check existing Docker, LAN, and VPN routes for conflicts before starting; configure APP_SUBNET to a non-overlapping range if needed. Coordinate any network changes with the existing runner and services. Do not expose PostgreSQL publicly.

PostgreSQL may see a container client address different from the laptop's address. The laptop's working HBA rule is therefore not proof this job can connect. Inspect the actual client address in PostgreSQL logs and existing firewall/HBA rules. A new narrow rule may be needed for the default app subnet:

hostssl  car_tracker  car_tracker  172.30.17.0/28  scram-sha-256

Use the configured subnet and actual observed addressing, not a blindly copied rule. HBA rules are first-match; preserve unrelated rules and permit only the necessary source/port through the firewall. Check pg_hba_file_rules for errors and reload authentication changes with SELECT pg_reload_conf(); as an admin. Changing listen_addresses requires a PostgreSQL restart: coordinate downtime for the shared VM's clients rather than restarting casually. See PostgreSQL troubleshooting for details. Use the existing car_tracker database and role on this VM.

Build And Smoke Run

Validate without printing interpolated configuration, then build on the homelab:

docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml config --quiet
docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml build app
docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app car-tracker-db check

check is read-only connectivity testing, not a schema or migration-privilege check. The pipeline additionally checks access to silver.collection_runs before opening the browser. No new migrations are required for this milestone. If the existing packaged migrations have not yet been applied, apply them explicitly:

docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app car-tracker-db migrate

Migrations are not automatic. Once DB access/schema are ready, run the image's default bounded headed pipeline: Saab 900, one page per selected search, at most one unique detail request for the run, with a randomized 5–7.5-second request delay by default.

docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app

Inspect the reported manifest and import outcome before expanding. After a usable smoke run, this is the explicit larger manual command:

docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app \
  car-tracker-pipeline --headed --config /app/searches.toml --output /data \
  --family saab-900 --max-pages 3 --max-details 5

Limits can intentionally produce partial coverage. Do not loop or retry a blocked source automatically; stop and investigate access failures before another scrape.

Outcomes And Recovery

The pipeline prints Manifest: /data/<date>/<run-id>/manifest.json, reports a JSON summary after successful import, and imports that exact finalized run, including partial or failed collection runs. Host files are under /srv/car-tracker/raw/<date>/<run-id>/. Import success does not imply complete source coverage. Keep finalized raw files and manifests immutable.

Pipeline exit Meaning
0 Complete collection imported, without rejected records
1 Incomplete collection imported, without rejected records
2 Import or replay has rejected records; inspect validation issues
3 Operational, configuration, locking, or database failure
130 Cancellation/interruption

pipeline.json is a separate operational sidecar, not a rewrite of raw coverage. It records collection_status, import_status, and an import summary on success. An interrupted import or lost DB response can leave import_status: unknown: the transaction may already have committed. Recover by idempotently replaying the exact manifest with the DB CLI, not by rerunning the scrape. Substitute the exact date/run ID printed by the pipeline:

docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app \
  car-tracker-db import '/data/<date>/<run-id>/manifest.json'

An identical replay reports already_imported without duplicating observations. The standalone DB CLI does not update pipeline.json; retain its replay result separately. Its exit codes differ; see import documentation. Do not alter raw files to force a replay past integrity checks.

browser_cleanup.json may record a nonfatal browser teardown error after raw finalization; the pipeline can still import that run. Allow graceful cancellation to finish. A hard kill, OOM, or expired stop grace can leave a running manifest, no sidecar, or stale running import state. A running manifest is not imported; investigate rather than marking it complete or assuming a DB rollback occurred.

The output-root .pipeline.lock covers the whole manual pipeline job, including collection and import. Do not delete an active lock file; the file can remain after exit and its presence alone does not mean a job is active. Standalone collection and DB CLIs do not honor this lock. Never overlap them with a pipeline or other manual job using the same output root, including during recovery.

Runtime Boundaries

Compose runs non-root with a read-only root filesystem, all capabilities dropped, and no-new-privileges. There is no Docker socket mount or published port. Runtime limits are 1.5 CPUs, 2 GiB memory, 256 PIDs, 512 MiB shared memory, 512 MiB /tmp tmpfs, a 1 MiB X11 socket tmpfs, and a 30-second stop grace period. These do not constrain image builds. Keep load low on the VM shared with Gitea, its runner, and PostgreSQL.

The image installs Chromium matching the locked Playwright version. Its custom Python entrypoint starts Xvfb for headed operation, forwards SIGTERM to the child, and waits for child cleanup before removing remaining processes and the display. No external GUI or noVNC service is needed.

Chromium's sandbox remains disabled by Playwright's default launch behavior; an explicit sandbox trial failed under host security restrictions. These container settings are not a claim of secure browser isolation. Do not add privileged mode, SYS_ADMIN, or seccomp=unconfined to work around this. The browser container can read mounted DB credentials and shares the VM kernel. Prefer a separate VM for future browser isolation rather than weakening the shared host's protections.

Verification And Retention

Live source access from this homelab has not been verified here. All 109 tests passed inside the built non-root image with its read-only filesystem and resource limits, using isolated PostgreSQL 17 and synthetic browser responses. An actual Docker stop also allowed the child to finish cleanup and exit 130 before the display was removed. These tests do not guarantee live source access. For optional testing, see PostgreSQL tests and tests/test_browser_pipeline.py. Browser tests require RUN_BROWSER_TESTS=1 and TEST_DATABASE_URL; use only a disposable database explicitly ending in _test, never homelab production. Tests truncate data. Default CI skips browser tests.

Retention is still manual: start with 30 days for raw files and private database copies, including descriptions and raw payloads, and account for relevant backups. Deleting raw files does not delete DB copies. No retention or backup automation is provided here; arrange and verify private backups manually before relying on the data. Keep raw files, DB copies, and backups private and outside Git/CI artifacts.