Files
factory/vibe/guidebooks/applications/webapp.md
Gabriel Radureau 4823394e0e docs(vibe): add applications/ guidebook (webapp + url-shortener)
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>
2026-06-23 21:58:36 +02:00

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. webapp stands in as the redirect target: /oauth-callback stores the code keyed by the client-chosen state in an in-memory cache (5-minute TTL), and a CLI client then polls /retrieve?state=… to exchange its state for the code. /retrieve is IP-gated — it only answers callers in the LAN CIDRs (192.168.0.0/16, the IPv6 prefix, the k3s 10.42.0.0/16) or an explicit OAUTH_DEVICE_CODE_ALLOWED_IPS allow-list — which is exactly why the pod must see the real client IP (see the nodeSelector note 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 falseHPA disabled; the single replica is fixed

Note

The internal .lab ingress is shipped as a hardcoded localIngress.yaml (a plain manifest, not the templated ingress.yaml), and the equivalent block in values.yaml is left commented out. That is why webapp has two ingress templates but the values file only configures the .fr public one. The split — web/CrowdSec public vs websecure/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 (path postgres/creds/webapp → Secret vso-db-credentials, with a rolloutRestart on the Deployment) and the matching iac/ role together stand up the entire per-app dynamic-credentials machinery end to end. But the running Deployment takes DATABASE_URL from the ConfigMap, which points at the shared static pgbouncer_auth user (postgres://pgbouncer_auth:pgbouncer_auth@pgbouncer.tools/postgres?sslmode=disable), and it does not mount the vso-db-credentials Secret. So webapp demonstrates 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 the vso-db-credentials Secret (e.g. project its fields into DATABASE_URL instead 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 the webapp_role Postgres 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-existing webapp_role; if webapp_role does 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_roles module that erp/dance-lessons-coach use instead of webapp's inline declarations.
  • factory postgres-iac — creates the webapp database and webapp_role that webapp's GRANT depends on.
  • tofu CI apply flow — the Gitea OIDC → Vault JWT → tofu apply pipeline behind vault.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.