Example: Invoicer
The generic recipe is in Onboard an app. This page is Invoicer filled in on this Project Loom
node. Same workloads/<app>/{base,overlays} shape, same Infisical wiring and same staging-to-prod promotion as
Talon. What differs is the database: Invoicer keeps its data in SQLite on a volume, not in a
CNPG cluster, and that one choice changes the workload kind and the rollout.
flowchart LR
Push([push app staging]) --> CI[checks → GHCR sha]
CI --> Stg[overlays/staging newTag]
Stg --> Argo[Argo CD]
Promo[push staging:main] --> Prod[overlays/prod same digest]
Inf[Infisical GUI] -->|operator|Sec[(invoicer-env)]
Sec --> Dep[Deployment, 1 pod]
Argo --> Dep
Dep --> PVC[(invoicer-data PVC)]
Dep --> Got[invoicer-gotenberg]
Staging host test.invoicer.visarend.systems, prod invoicer.visarend.systems. Namespaces invoicer-staging /
invoicer-prod. Server-rendered HTML with a session cookie; no Keycloak client, no separate web app.
Manifests
Section titled “Manifests”ApplicationSet rows in apps/workloads.yaml. Files under workloads/invoicer/base/:
| File | What |
|---|---|
deployment.yaml | The app. replicas: 1, strategy: Recreate, the PVC mounted at /data |
pvc.yaml | invoicer-data, 1Gi, local-path, ReadWriteOnce |
gotenberg.yaml | HTML-to-PDF Deployment + Service, pinned version, no Ingress (namespace-internal) |
service.yaml, ingress.yaml | As Talon’s. Host overlay-patched |
infisical-*.yaml | The three Infisical CRs. Syncs into invoicer-env |
Overlays patch the namespace, host, Infisical slug, image tag and BASE_URL. The staging overlay adds a Traefik
Middleware that sets X-Robots-Tag: noindex, nofollow, attached with the
traefik.ingress.kubernetes.io/router.middlewares: invoicer-staging-noindex@kubernetescrd annotation, so search engines
never list UAT.
Infisical project invoicer, cells dev, staging and prod, holding the RETENTION_* limits, the SMTP settings
(SMTP_*, MAIL_FROM), EMAIL_DAILY_CAP and DATABASE_RESET_ON_SCHEMA_CHANGE. dev leaves SMTP_HOST empty, so local
emails print to the terminal. Git holds what must match the manifests: BASE_URL beside the ingress host, DATABASE_PATH beside the volume
mount, GOTENBERG_URL beside its Service name, and the container port.
Why a Deployment and not a Rollout
Section titled “Why a Deployment and not a Rollout”SQLite allows one writer, and a local-path volume is ReadWriteOnce. A canary would run old and new pods against the
same file at once. So: a plain Deployment with Recreate. The old pod stops, then the new one starts. The cost is a
few seconds of no available server on every deploy (see
Troubleshooting). Staging and
prod are identical here; there is no canary step to patch per environment.
The pod runs as distroless’s nonroot by number (runAsUser: 65532, runAsGroup: 65532, runAsNonRoot: true) with
fsGroup: 65532 so it can write the volume, and the image declares USER 65532:65532 as well.
Runtime
Section titled “Runtime”The app creates its schema on first start and refuses a database made from a different schema (no migrations before
launch), unless DATABASE_RESET_ON_SCHEMA_CHANGE=true, which deletes and remakes it. That is set in staging and
prod until launch, so a schema change needs no manual reset. /healthz is process-only and backs liveness; /readyz pings SQLite and backs readiness. A 5s preStop
sleep keeps the pod serving while Traefik drops its endpoint. Background work (session sweep, document retention) runs
in-process on a ticker, which is safe only because there is exactly one pod.
Email goes through OCI Email Delivery as invoicer@mail.visarend.systems: sign-in links, and quotes, invoices and
receipts with their PDF attached. It is the worked example of Onboard § Email; Invoicer’s
internal/mail package is the reference SMTP client (STARTTLS, PLAIN or LOGIN, timeouts, trimmed settings). An
environment without SMTP_HOST prints emails to the pod log instead and shows the sign-in link on the page.
Local dev
Section titled “Local dev”As Talon: .infisical.json with defaultEnvironment: dev, and the committed .run/Air - Development.run.xml runs
infisical run -- go tool air (air is pinned as a Go tool rather than installed globally). Every setting has a local
default, so plain go tool air also works without Infisical.
Backups
Section titled “Backups”Not yet. CNPG’s barman backups don’t apply to SQLite. The plan for launch is a Litestream sidecar replicating the database continuously to the same Oracle object storage the Postgres backups use, with a restore test before real data arrives.