Preskoči na sadržaj

Local Development (k3d)

Source of truth

hns-platform/scripts/local-up.sh + local-down.sh, playbooks/local.yml, inventory/local-k3d.vars.yml, and components.local.yml. Update this page when those change.

make local-up brings the whole HNS stack up on a throwaway local cluster — backend, both portals, Keycloak, mailer, eventbus, waiting-room, mobile-web, plus an in-cluster Postgres — over real HTTPS. It runs the same Ansible role and renders the same Kubernetes manifests as the production deploy, so what you exercise locally behaves like the cluster.

This is not the docker-compose local mode

There are two local paths, for two different jobs:

  • docker-compose (per service, *.hns.docker.localhost) — iterate on one service in isolation, fastest inner loop. Documented per-service in each repo.
  • k3d make local-up (this page) — the whole stack together on real k3s + Traefik + ingress + TLS. For integration/e2e work, cross-service flows, and the browser-direct mobile-web SPA.

Use docker-compose when you live inside one service; use k3d when you need the system to behave like the cluster.

Why it is shaped this way

The guiding principle: "close to the cluster" means preserving the app-facing contract, not the infrastructure transport.

What the application sees is kept identical to production — the rendered manifests, Traefik ingress, env-var contract, in-cluster service DNS, and the TLS/cert shape. What it doesn't see is simplified freely:

  • how images arrivek3d image import from a local build, instead of a push/pull through the internal registry;
  • where Postgres lives — a disposable in-cluster pod, instead of bare-metal db-01;
  • how it is trusted — a per-developer mkcert certificate, instead of a Let's Encrypt one.

This is why a service can't tell the difference: the parts it depends on are the same, and only the plumbing underneath changed. The deploy itself is literally the production hns-service role — playbooks/local.yml is a thin local entry point parallel to services.yml, looping the same role over components.local.yml.

Prerequisites

make local-up checks all of these on startup and prints an install hint for anything missing.

Tool What it is Why local-up needs it
k3d k3s (lightweight Kubernetes) packaged to run inside Docker Creates the local cluster — a real k3s, just containerised
Docker Container engine Runs k3d's node containers and builds each service's image
kubectl Kubernetes CLI Talks to the cluster (rollout waits, exec, the TLS secret)
Ansible + kubernetes.core collection + kubernetes Python lib The deploy engine Runs the same hns-service role as production; the collection's k8s module (and its Python client) apply the manifests
mkcert + libnss3-tools A local certificate authority + cert generator; libnss3-tools provides certutil, which registers the CA in the browser trust store on Linux Issues a locally-trusted wildcard TLS cert so the browser accepts https://*.127.0.0.1.sslip.io (see TLS)

Quick start

# 1. One-time per machine — install prerequisites (Ubuntu shown; k3d/docker/kubectl/ansible per their own docs)
sudo apt-get install -y mkcert libnss3-tools
mkcert -install        # trust your local CA — once per machine (may prompt for sudo to write the system trust store)

# 2. Bring the whole stack up (first run is slow — every image builds once)
make local-up

# 3. Tear it all down (deletes the cluster; your cert + CA are kept)
make local-down

Order matters: libnss3-tools before mkcert -install

mkcert -install only registers the CA in the browser if certutil (from libnss3-tools) is already present. If you run -install first, install libnss3-tools and re-run mkcert -install — otherwise the browser keeps rejecting the cert with ERR_CERT_AUTHORITY_INVALID.

On success you get (default ports 80/443; override with HTTP_PORT=… HTTPS_PORT=… make local-up if taken):

URL Service
https://api.127.0.0.1.sslip.io/api/v1 Backend REST API
https://admin.127.0.0.1.sslip.io Admin portal
https://quota.127.0.0.1.sslip.io Quota portal
https://idp.127.0.0.1.sslip.io Keycloak
https://mobile.127.0.0.1.sslip.io Mobile-web SPA
https://ntfy.127.0.0.1.sslip.io ntfy (dev push viewer)

Seed users: admin@hns.hr / HnsAdmin123! (SUPER_ADMIN) and fan@hns.test / HnsFan123!.

How it works

The cluster

make local-up
  ├─ check prerequisites (+ install hints)
  ├─ k3d cluster create 'hns'
  │     --volume <workspace>:/workspace        (live-mount source into the node)
  │     -p 80:80 -p 443:443 @loadbalancer       (host ports → Traefik)
  ├─ mkcert  *.127.0.0.1.sslip.io               (regenerate-if-missing → .local-tls/)
  ├─ ansible-playbook playbooks/local.yml
  │     ├─ namespace 'hns'
  │     ├─ TLS secret hns-portal-tls            (from the mkcert cert)
  │     ├─ in-cluster Postgres (hns-local-postgres)
  │     └─ loop components.local.yml → role hns-service   (build → k3d import → apply)
  └─ seed-composition.yml                        (users + reference content)

k3d is k3s running inside Docker — a certified, full Kubernetes, not a simulator. So Traefik, ingress, ClusterIP DNS, probes, and rollouts all behave as they do in the cluster.

The source map

playbooks/local.yml reads components.local.yml — a local-only file answering, per service, just three questions:

  • path — the repo directory under the workspace root;
  • live — live-mount the source (PHP/Symfony services only — see below);
  • mount_dirs — which directories to mount.

It is never a second .platform.yml: each service is still deployed from its own committed .platform.yml. components.local.yml only adds the local facts.

Image delivery — k3d image import, no registry

Locally there is no internal registry. The role builds each image with docker build and loads it straight into the cluster with k3d image import (selected by image_delivery: k3d in inventory/local-k3d.vars.yml). Production keeps the registry push/pull unchanged — the build/deliver step is dispatched, so the rest of the pipeline is identical.

Why no registry locally

A registry exists to move images between machines. Locally the build and the cluster are the same machine, so k3d image import is the whole job — and it drops the registry container, the /etc/hosts and Docker-daemon edits it needed, and the sudo that came with them.

Database — in-cluster Postgres

local.yml deploys a disposable hns-local-postgres (emptyDir — gone on local-down) and points db_host at it. The DSN contract is identical to production; only the physical placement differs — the application reaches Postgres by service name either way. (Keycloak and waiting-room keep their own in-cluster Postgres in both modes.)

DNS — *.127.0.0.1.sslip.io

sslip.io is a public DNS service that returns the IP embedded in the hostname: anything.127.0.0.1.sslip.io resolves to 127.0.0.1. Traefik listens there on port 80/443 and routes by the Host header (the api./admin./… prefix), which sslip.io ignores for resolution.

Why sslip.io instead of /etc/hosts

Every subdomain must resolve to 127.0.0.1 (where Traefik listens) while keeping a distinct Host for routing. /etc/hosts needs sudo, can't wildcard, and must be kept in sync per host; sslip.io is a wildcard with zero setup. Only the DNS lookup leaves the machine — the traffic itself stays on loopback.

Live editing — APP_ENV=dev

For the PHP/Symfony services marked live: true (backend, admin, quota), the role hostPath-mounts the source from your workspace into the pod and sets APP_ENV=dev. Symfony then recompiles edited src/config/templates on the next request — no rebuild, no redeploy. Compiled services (Keycloak, mailer, eventbus, waiting-room, mobile-web) are live: false; a change there is picked up by make local-redeploy SVC=<name> (rebuild + re-import + rollout restart).

TLS — locally-trusted via mkcert

Every ingress already renders tls: → secretName: hns-portal-tls (see TLS & Ingress) and host port 443 is mapped — the only missing piece locally is a trusted certificate. local-up.sh generates a wildcard cert for *.127.0.0.1.sslip.io with mkcert and local.yml loads it into the hns-portal-tls secret. The cluster issues this secret via cert-manager + Let's Encrypt in production; that role is never invoked locally.

Why a self-signed cert, and why per-developer

Self-signed, not Let's Encrypt: LE only issues for a domain it can reach over the public internet to verify ownership. 127.0.0.1.sslip.io points at your own laptop, unreachable from outside — so LE cannot issue, and a locally-generated cert is the only option.

Per-developer, not a shared CA: mkcert keeps each developer's CA on their own machine and never shares it. A shared CA (one private key in a vault, signing for everyone) would mean anyone holding that key could issue a cert for any domain that every developer's browser would trust — a real MITM risk. Per-developer trust avoids the shared secret entirely.

The cost is one-time per machine: mkcert -install (and libnss3-tools so it reaches the browser store). The cert itself is stable across local-down/local-up, so you trust the CA only once.

What mkcert -install actually does (once per machine): it creates a local CA in $(mkcert -CAROOT) (Linux: ~/.local/share/mkcert/) — rootCA.pem (the public CA cert) and rootCA-key.pem (its private key, never shared) — then registers rootCA.pem in two trust stores: the system store (/usr/local/share/ca-certificates via update-ca-certificates — hence the sudo prompt), used by tools like curl; and the browser NSS store (Chrome's ~/.pki/nssdb plus Firefox profiles, written via certutil from libnss3-tools). The wildcard cert in the hns-portal-tls secret is signed by this CA, so once the CA is in the trust store the browser validates the chain (leaf → CA) and shows a green lock — before that the CA is unknown, which is the ERR_CERT_AUTHORITY_INVALID you would otherwise hit.

Undo with mkcert -uninstall — it removes the CA from every trust store. The CA files in CAROOT stay on disk (a later mkcert -install reuses the same CA); delete that folder to remove them entirely.

Seeding

seed-composition.yml runs last and converges the namespace: it creates the Keycloak users, JIT-provisions their backend rows via a real token round-trip, promotes the admin to SUPER_ADMIN, and loads reference content (stadiums, a test match, the Petrol QA dataset). It is idempotent — re-runs converge in seconds.

How it differs from the cluster

Aspect Local (k3d) Cluster (k3s)
Image delivery k3d image import from a local build Push to / pull from the internal registry
Postgres Disposable in-cluster pod Bare-metal db-01
TLS issuer Per-developer mkcert CA cert-manager + Let's Encrypt
DNS *.127.0.0.1.sslip.io (wildcard, no setup) Real domain via DOMAIN
Live edit PHP services hostPath-mounted, APP_ENV=dev Baked image, APP_ENV=prod

Identical in both: the hns-service role and rendered manifests, Traefik ingress + the hns-portal-tls secret shape, the env-var contract, in-cluster service DNS, and every service's own .platform.yml.

Common tasks

Task Command
Bring the stack up make local-up
Tear it down (keeps cert/CA) make local-down
Edit a live service (backend/admin/quota) Just save the file — reflected on the next request
Rebuild a compiled service make local-redeploy SVC=hns-mailer
Use different host ports HTTP_PORT=8200 HTTPS_PORT=8443 make local-up
Force a fresh TLS cert rm -rf hns-platform/.local-tls && make local-up
Inspect pods kubectl -n hns get pods

Last Updated: June 2026