Tree-docs guidebook under vibe/guidebooks/applications/ documenting the common app pattern and two contrasting archetypes, drilling into lab-ecosystem/01-factory (bidirectional): - README.md : the shared app pattern (repo = Dockerfile + chart + optional iac + CI; ArgoCD app-of-apps; the <app> join key; .fr vs .lab ingress conventions) + a two-archetype comparison. - webapp.md : canonical Go + Postgres exemplar (chart, VaultAuth/Static/Dynamic CRDs, inline iac vs the shared app_roles module, CI); notes the current nuance that the live pod still uses the static pgbouncer_auth DATABASE_URL. - url-shortener.md : Rust + SQLite-on-Longhorn-RWO counterpart (single replica, no iac/no Vault, CI mirrors the upstream image); the power-cut recovery story. erp is referenced in prose only (its own guidebook lands next). Sibling-repo code via full gitea URLs; 2 mermaid diagrams MCP-validated; zero dead links. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
vibe > Guidebooks > Applications > webapp
webapp
Status: ✅ Active Last Updated: 2026-06-23 Upstream: Applications · 01 · factory · tools secrets-and-vso Downstream: the template every simple Postgres-backed app (
erp,dance-lessons-coach) is cloned from Related: url-shortener · naming-conventions · secrets-and-vault concept · factory postgres-iac · tofu CI apply flow · safe-prod-like-environment ADR
webapp is the canonical simple-app exemplar — a deliberately small Go diagnostic app whose whole job is to exercise the lab's plumbing so every other Postgres-backed app can be cloned from its shape. It ships the four ingredients of the common app pattern (Dockerfile, chart/, iac/, .gitea/workflows) in their most legible form, with no business logic to read around. When you add a new simple app, this repo is the skeleton you copy.
What it actually does at runtime is a handful of probes:
| Endpoint | Purpose |
|---|---|
GET / |
Serves an HTML form that posts a number to /query |
GET /query?param=N |
Runs the parameterized query SELECT 42 + $1 against Postgres and renders the result — the end-to-end "is the DB reachable and answering" check |
GET /liveness |
Always-200 OK liveness probe (no DB touch) |
GET /readiness |
Calls db.Ping(); returns 503 NOT READY if Postgres is unreachable — so the pod only takes traffic once the DB is live |
GET /display-info |
Dumps the request's cookies, client IP, and headers — used to confirm the real client IP survives the ingress path |
GET /oauth-callback, /retrieve, /test-oauth-callback |
OAuth device-flow test endpoints (see below) — a workaround for Gitea lacking the OIDC device grant |
Note
The OAuth endpoints exist because Gitea's OIDC provider only advertises
authorization_code+refresh_token, not the device grant.webappstands in as the redirect target:/oauth-callbackstores thecodekeyed by the client-chosenstatein an in-memory cache (5-minute TTL), and a CLI client then polls/retrieve?state=…to exchange itsstatefor thecode./retrieveis IP-gated — it only answers callers in the LAN CIDRs (192.168.0.0/16, the IPv6 prefix, the k3s10.42.0.0/16) or an explicitOAUTH_DEVICE_CODE_ALLOWED_IPSallow-list — which is exactly why the pod must see the real client IP (see thenodeSelectornote in the chart).
1) The app & image
A single-file Go program with two third-party dependencies, built into a tiny runtime image.
| Aspect | Value |
|---|---|
| Source | main.go — one file, package main |
| Module | go.mod · gitea.arcodange.lab/arcodange-org/webapp · Go 1.23 |
| Postgres driver | github.com/lib/pq v1.10.9 — registered as the postgres driver; the connection string comes from DATABASE_URL |
| Cache | github.com/patrickmn/go-cache v2.1.0 — the in-memory state → code store for the OAuth callback (5 min default expiry, 10 min cleanup) |
| Listen port | :8080, plain HTTP (net/http default mux) — TLS is terminated upstream at Traefik |
| Query | SELECT 42 + $1 — parameterized ($1 bound, not interpolated) so the diagnostic endpoint is not an injection vector |
| Dockerfile | Dockerfile — multistage: golang:1.23-alpine builder (go build -o app .) → alpine:latest runtime with ca-certificates, EXPOSE 8080, CMD ["./app"] |
| Image | gitea.arcodange.lab/arcodange-org/webapp — pushed to the Gitea container registry by CI (tags latest + the git ref name) |
The runtime image carries no config of its own: everything (DATABASE_URL, OAUTH_ALLOWED_HOSTS, the optional OAUTH_DEVICE_CODE_ALLOWED_IPS) arrives as environment variables from the chart's ConfigMap.
2) The chart
The Helm chart at chart/ (Chart.yaml: name: webapp, appVersion: "latest") is the unit ArgoCD syncs into the webapp namespace. It is the boilerplate helm create scaffold plus the Vault CRDs and a hardcoded second ingress.
| Chart object | Template | Shape |
|---|---|---|
| Deployment | deployment.yaml |
replicaCount: 1; revisionHistoryLimit: 3; one container on containerPort: 8080; envFrom the ConfigMap; liveness + readiness probes |
| Node pinning | nodeSelector in values.yaml |
kubernetes.io/hostname: pi1 — pinned to the network entrypoint node so traffic avoids NAT and the pod sees the real client IP (load-bearing for the IP-gated /retrieve and for /display-info) |
| ConfigMap | config.yaml |
OAUTH_ALLOWED_HOSTS: webapp.arcodange.lab,webapp.arcodange.fr; DATABASE_URL: postgres://pgbouncer_auth:pgbouncer_auth@pgbouncer.tools/postgres?sslmode=disable |
| Public ingress | ingress.yaml (values-driven) |
host webapp.arcodange.fr, Traefik web entrypoint (HTTP), middleware kube-system-crowdsec@kubernetescrd (the CrowdSec bouncer) |
| Internal ingress | localIngress.yaml (hardcoded manifest) |
Ingress/webapp-local, host webapp.arcodange.lab, Traefik websecure entrypoint, letsencrypt certresolver, middleware localIp@file (LAN-only) |
| Service | service.yaml |
ClusterIP on port 8080 → http target |
| ServiceAccount | serviceaccount.yaml |
created as webapp, token auto-mounted — the identity VSO uses to authenticate to Vault |
| Probes | livenessProbe / readinessProbe in values.yaml |
liveness → /liveness (cheap), readiness → /readiness (pings the DB), both on the http port |
| HPA | hpa.yaml |
gated on autoscaling.enabled, which is false — HPA disabled; the single replica is fixed |
Note
The internal
.labingress is shipped as a hardcodedlocalIngress.yaml(a plain manifest, not the templatedingress.yaml), and the equivalent block invalues.yamlis left commented out. That is why webapp has two ingress templates but the values file only configures the.frpublic one. The split —web/CrowdSec public vswebsecure/localIp/letsencrypt internal — is the lab-wide ingress convention.
3) Vault CRDs in the chart
The chart ships the full Vault Secrets Operator (VSO) wiring as three CRDs. Together they let the pod authenticate to Vault as webapp and pull both static config and dynamic DB credentials.
| CRD | Template | What it declares |
|---|---|---|
| VaultAuth | vaultauth.yaml |
kubernetes auth method on mount kubernetes, role webapp, ServiceAccount webapp, audience vault — the login other CRDs reference via vaultAuthRef: auth |
| VaultStaticSecret | vaultsecret.yaml |
kv-v2 on mount kvv2, path webapp/config → k8s Secret secretkv (created by VSO), refreshAfter: 30s |
| VaultDynamicSecret | vaultdynamicsecret.yaml |
mount postgres, path creds/webapp → k8s Secret vso-db-credentials (created by VSO), with a rolloutRestartTargets entry on the webapp Deployment so the pod restarts when creds rotate |
The runtime path these ride — VSO reading Vault on the pod's behalf and materializing k8s Secrets — is documented in tools secrets-and-vso.
4) The key nuance — wiring shipped, live pod still on static creds
Note
webapp provisions the complete dynamic-DB-credentials path but does not yet consume it. The chart's
VaultDynamicSecret(pathpostgres/creds/webapp→ Secretvso-db-credentials, with arolloutRestarton the Deployment) and the matchingiac/role together stand up the entire per-app dynamic-credentials machinery end to end. But the running Deployment takesDATABASE_URLfrom the ConfigMap, which points at the shared staticpgbouncer_authuser (postgres://pgbouncer_auth:pgbouncer_auth@pgbouncer.tools/postgres?sslmode=disable), and it does not mount thevso-db-credentialsSecret. Sowebappdemonstrates the dynamic-creds wiring in full as a reference, while its live pod runs on the shared static account. Switching the live pod to rotating per-app credentials is simply a matter of consuming thevso-db-credentialsSecret (e.g. project its fields intoDATABASE_URLinstead of the ConfigMap value). This is the current state, by design as an exemplar — not a misconfiguration.
This is the one place to read webapp carefully: as a template it shows you every CRD and IaC resource a dynamic-creds app needs; as a deployed workload it is still on the shared pooler user. When you clone it for a real app, the last step is to wire the pod to vso-db-credentials.
5) iac/ — Vault objects declared inline
The OpenTofu under iac/ declares webapp's Vault objects. Unlike erp and dance-lessons-coach, which call the shared app_roles module from tools (see tools secrets-and-vso), webapp declares them inline in main.tf — which is exactly why it reads as the legible reference: every resource is visible in one file rather than hidden behind a module call.
| File | Contents |
|---|---|
providers.tf |
vault provider v4.4.0 at https://vault.arcodange.lab; authenticates via auth_login_jwt { mount = "gitea_jwt", role = "gitea_cicd_webapp" } (token from TERRAFORM_VAULT_AUTH_JWT) |
backend.tf |
GCS backend, bucket arcodange-tf, prefix webapp/main |
main.tf |
three inline resources (below) |
The three resources in main.tf:
| Resource | Vault object | Detail |
|---|---|---|
vault_database_secret_backend_role.role |
postgres/creds/webapp |
creation SQL CREATE ROLE "{{name}}" WITH LOGIN PASSWORD … VALID UNTIL '{{expiration}}' then GRANT webapp_role TO "{{name}}"; revocation REVOKE ALL ON DATABASE webapp FROM … |
vault_kubernetes_auth_backend_role.role |
k8s auth role webapp |
bound to SA webapp + namespace webapp, audience = vault, token_policies = ["default", "webapp"], token_ttl = 3600 |
vault_kv_secret_v2.webapp_config |
kvv2/webapp/config |
the KV-v2 config secret VSO reads into the secretkv k8s Secret |
Important
The
GRANT webapp_role TO …statement depends on thewebapp_rolePostgres group role being created first by factory's postgres-iac. webapp's IaC mints short-lived login roles that inherit the privileges of that pre-existingwebapp_role; ifwebapp_roledoes not exist, the dynamic-credential creation fails at grant time.
6) CI workflows
Two Gitea Actions workflows under .gitea/workflows/, each gated to the part of the repo it owns.
| Workflow | File | Trigger | What it does |
|---|---|---|---|
| Hashicorp Vault | vault.yaml |
push / PR touching iac/*.tf (+ manual) |
a gitea_vault_auth job mints the Gitea OIDC token, then a tofu job runs terraform apply on iac/ with OpenTofu 1.8.2; reads kvv1/google/credentials for the GCS backend; VAULT_CACERT is built from the HOMELAB_CA_CERT secret |
| Docker Build | dockerimage.yaml |
push to main (ignoring README.md, chart/**) + manual |
logs into the Gitea registry with PACKAGES_TOKEN, docker build, pushes latest and the git ref-name tag to gitea.arcodange.lab/<repo> |
The vault.yaml flow — Gitea OIDC → Vault JWT login → tofu apply with GCS state — is the lab-standard CI apply path described in tofu CI apply flow. The image is then rolled out cluster-side: ArgoCD Image Updater (factory side) watches the registry on a digest strategy and bumps the running Deployment when the latest digest changes.
7) <app> convention mapping for webapp
The single string webapp is the join key threading through every layer of the stack (the lab-wide naming convention):
| Layer | Value for webapp |
|---|---|
| Gitea repo | arcodange-org/webapp |
| Container image | gitea.arcodange.lab/arcodange-org/webapp (tags latest + ref-name) |
| PostgreSQL | database webapp, group role webapp_role (from factory postgres-iac) |
| Vault — dynamic DB | postgres/creds/webapp |
| Vault — KV config | kvv2/webapp/config |
| Vault — k8s auth role | webapp (policies default, webapp; SA + ns webapp) |
| Vault — CI JWT role | gitea_cicd_webapp (mount gitea_jwt) |
| Terraform state | GCS arcodange-tf prefix webapp/main |
| Kubernetes | namespace webapp, ServiceAccount webapp |
| ArgoCD | Application webapp (chart synced into ns webapp) |
| Ingress hosts | public webapp.arcodange.fr · internal webapp.arcodange.lab |
Cross-references
- url-shortener — the stateful contrast: Rust + embedded SQLite on a Longhorn PVC, single replica, no
iac/and no Vault objects at all. webapp (shared/scalable Postgres) vs url-shortener (self-contained single-writer SQLite) are the two archetypes of the Applications hub. - tools secrets-and-vso — the VSO runtime path the Vault CRDs ride, and the shared
app_rolesmodule thaterp/dance-lessons-coachuse instead of webapp's inline declarations. - factory postgres-iac — creates the
webappdatabase andwebapp_rolethat webapp'sGRANTdepends on. - tofu CI apply flow — the Gitea OIDC → Vault JWT →
tofu applypipeline behindvault.yaml. - naming-conventions — the
<app>join key tabulated in section 7. - safe-prod-like-environment ADR — why a throwaway diagnostic app is still deployed prod-like, complete with dynamic-creds wiring.