Guides
Deployment
Container images, the Kubernetes manifests, and the Traefik host-to-cluster bridge.
Two images, one namespace, one Ingress. The deployment target that has actually been exercised is a Kind cluster on a developer workstation, reached at http://webauthn-demo.localhost with no port number.
The images
Backend
Two stages, both eclipse-temurin:17.
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
COPY gradle/ gradle/
RUN ./gradlew --no-daemon dependencies || true
COPY src/ src/
RUN ./gradlew --no-daemon build -x test -Dquarkus.package.jar.type=uber-jar
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/build/*-runner.jar app.jar
EXPOSE 8090
ENV JAVA_OPTS="-Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
Build descriptors are copied before src/, so a source-only change reuses the cached dependency layer.
Three things are deliberate and one is a wart:
-Dquarkus.package.jar.type=uber-jarproduces a single fat jar, so the runtime stage is oneCOPYwith noquarkus-app/libtree to keep in sync.JAVA_OPTSsets the JBoss log manager, which Quarkus requires when started as a plain jar rather than through the Gradle plugin.RUN ./gradlew dependencies || trueswallows its own failure. The|| trueexists so a partial resolution still warms the cache, but it also means a genuinely broken build file produces a confusing failure one layer later.
The ENTRYPOINT goes through sh -c to get $JAVA_OPTS expanded, which means Java does not run as PID 1 and will not receive SIGTERM directly. On pod deletion the container relies on the kubelet's SIGKILL after the grace period rather than shutting down cleanly. Using the exec form with a JAVA_TOOL_OPTIONS env var would fix it.
Frontend
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/ packages/
RUN npm ci
RUN npm run sdk:build
COPY src/ src/
COPY next.config.mjs tsconfig.json next-env.d.ts ./
ARG BACKEND_URL=http://backend:8090
ENV BACKEND_URL=${BACKEND_URL}
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static .next/static
EXPOSE 3000
CMD ["node", "server.js"]
BACKEND_URL is an ARG because Next.js resolves rewrite destinations at build time. This is correct. The value is compiled into .next/routes-manifest.json during npm run build and cannot be changed afterwards. To point at a different backend you rebuild:
docker build --build-arg BACKEND_URL=http://backend.other:8090 -t frontend:latest .
The runtime stage copies only .next/standalone and .next/static, so no node_modules tree ships. The standalone bundle carries just the modules actually imported.
Add a .dockerignore before building either image. Without one, docker build sends the whole working directory as context — node_modules/, .next/, build/, .gradle/, data/ and recordings/ included — which on the frontend is hundreds of megabytes for a build that runs npm ci from scratch anyway.
The critical frontend detail
Next.js standalone binds to the hostname in HOSTNAME, and in a pod that variable is set by Kubernetes to the pod name, which resolves to the pod IP. The server then listens on that single address and rejects the kubelet and the Service, which connect via the cluster IP.
The symptom is a pod that is Running and logs Ready in 412ms while every request through the Service times out.
The fix is explicit in the Deployment:
env:
- name: HOSTNAME
value: "0.0.0.0"
This is the single non-obvious thing about deploying this application. Without it nothing works and the logs give no hint.
Manifests
k8s/deploy.yaml is one file with six documents: a Namespace, two Deployments, two Services and an Ingress.
| Resource | Detail |
|---|---|
Namespace |
mobilesigner-demo |
Deployment backend |
1 replica, port 8090, 256Mi/200m requested, 512Mi/500m limit |
Service backend |
ClusterIP, 8090 → 8090 |
Deployment frontend |
1 replica, port 3000, 128Mi/100m requested, 256Mi/300m limit |
Service frontend |
ClusterIP, 3000 → 3000 |
Ingress |
host webauthn-demo.localhost, class traefik |
Both images are pulled from registry.localhost:5001/mobilesigner-demo/…:latest. That hostname resolves inside the cluster; from the host the same registry is localhost:5002. Pushing to the wrong one of the two is the usual cause of ImagePullBackOff.
Using the :latest tag with the default imagePullPolicy means a rebuild pushed under the same tag may not be picked up. kubectl rollout restart is required, and a digest or a real tag would be better.
Backend environment
env:
- name: WEBAUTHN_ORIGIN
value: "http://webauthn-demo.localhost"
- name: WEBAUTHN_RP_ID
value: "webauthn-demo.localhost"
These two must match the browser's address exactly or every ceremony fails. WebAuthn binds a credential to its relying party id, and the browser refuses to use a credential under a different RP id.
Three different origin/RP pairs exist across the project:
| Where | Origin | RP ID |
|---|---|---|
application.properties defaults |
http://localhost:3000 |
localhost |
k8s/deploy.yaml |
http://webauthn-demo.localhost |
webauthn-demo.localhost |
scripts/record-demo.mjs (DEMO_BASE_URL) |
http://webauthn-demo.localhost |
— |
A credential enrolled against localhost in local development will not work against the cluster, and vice versa. That is correct WebAuthn behaviour — a credential is bound to its relying party id — so enrol once per environment and expect the mismatch to present as a key the browser will not offer.
SESSION_ENCRYPTION_KEY is not set on the backend container, so the default from application.properties applies:
quarkus.http.auth.session.encryption-key=${SESSION_ENCRYPTION_KEY:local-demo-session-key-32-bytes!}
That default is a development convenience: it keeps sessions working out of the box. Supply a real key from a Secret before this is reachable by anyone else, because the backend authorises requests from the cookie rather than by re-running an assertion. Operations has the manifest.
The Ingress
metadata:
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
ingressClassName: traefik
rules:
- host: webauthn-demo.localhost
http:
paths:
- path: /q/webauthn
pathType: Prefix
backend: { service: { name: backend, port: { number: 8090 } } }
- path: /
pathType: Prefix
backend: { service: { name: frontend, port: { number: 3000 } } }
ingressClassName: traefik is required. Without it the Ingress is not adopted by any controller and every request returns 404 from Traefik's default handler — with no error anywhere to explain why.
The /q/webauthn rule sends ceremony traffic straight to the backend, bypassing the Next.js proxy. Both paths therefore work in the cluster: the Ingress rule short-circuits it, and the Next rewrite would handle it otherwise. Order matters — the more specific prefix is listed first.
Same-origin is preserved either way, which is what keeps quarkus.webauthn.cookie-same-site=strict workable and means no CORS configuration is needed anywhere.
Traffic between the Ingress and both Services, and between the frontend and backend, is plain HTTP. Terminating TLS at the Ingress is the first thing to add for any shared environment.
Getting a hostname without a port
Kind runs its nodes as Docker containers. The cluster's Traefik is exposed on NodePort 30080, but Kind nodes have no host port mappings, so nothing on the host can reach it directly. Port-forwarding would work; the requirement here was service discovery and a URL with no port number.
One thing to know before debugging this: desktop-linux and default Docker contexts are separate daemons. A bridge container in a different context from the cluster cannot see the kind network at all, so it can never route to the nodes however it is configured — and it looks perfectly healthy while failing.
The working arrangement is a Traefik v3.7 container in the same context as the Kind cluster, attached to the kind Docker network so it can resolve node container names, publishing 80 and 443 on the loopback interface:
graph LR
Browser["Browser<br/><small>http://webauthn-demo.localhost</small>"]
Loop["127.0.0.1:80"]
Bridge["Traefik v3.7 bridge<br/><small>plain Docker container<br/>on the kind network</small>"]
Node["iitj-local-worker:30080<br/><small>Kind node container</small>"]
Cluster["Cluster Traefik<br/><small>ingress controller</small>"]
Ing["Ingress rules<br/><small>match on Host header</small>"]
Fe["Service frontend:3000"]
Be["Service backend:8090"]
Mini["Minikube ingress<br/><small>catch-all for other hosts</small>"]
Browser --> Loop --> Bridge
Bridge -->|"Host matches"| Node
Bridge -.->|"any other Host"| Mini
Node --> Cluster --> Ing
Ing -->|"/"| Fe
Ing -->|"/q/webauthn"| Be
classDef host fill:#e8f0f9,stroke:#1d4e89,color:#18221d
classDef k8s fill:#e4f4ec,stroke:#0d6b4f,color:#18221d
classDef other fill:#eef0f2,stroke:#4a5568,color:#18221d
class Browser,Loop,Bridge host
class Node,Cluster,Ing,Fe,Be k8s
class Mini other
http:
routers:
webauthn-demo:
rule: Host(`webauthn-demo.localhost`)
entryPoints: [web]
service: kind-cluster-traefik
services:
kind-cluster-traefik:
loadBalancer:
servers:
- url: http://iitj-local-worker:30080
iitj-local-worker is the Kind worker node's container name, resolvable only from inside the kind network. The bridge forwards to the NodePort, the cluster Traefik matches the Host header against the Ingress, and the request lands on the right Service.
*.localhost resolves to 127.0.0.1 in browsers without any /etc/hosts entry, which is what makes the hostname work with no local DNS setup.
The bridge configuration also keeps catch-all routers for a co-resident Minikube, so both clusters share ports 80 and 443, discriminated by Host header.
One operational caveat: this bridge is a plain Docker container, not a cluster resource, and it does not restart with the cluster. If http://webauthn-demo.localhost stops resolving, check it is running before looking at anything in Kubernetes.
The database
k8s/postgres.yaml carries PostgreSQL 17 as a StatefulSet with a volumeClaimTemplate, a headless Service, and a Secret holding the credentials.
| Resource | Detail |
|---|---|
Secret mobilesigner-db |
database name, user and password |
Service postgres |
headless (clusterIP: None) — one writer to address by stable DNS |
StatefulSet postgres |
1 replica, postgres:17.2-alpine, pg_isready probes |
PVC data-postgres-0 |
2Gi ReadWriteOnce, created from the volume claim template |
Two details that matter:
PGDATA is set to a subdirectory of the mount, not the mount root:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
A freshly provisioned volume often contains lost+found, and initdb refuses to initialise into a non-empty directory. Pointing at a subdirectory avoids a first-boot failure that reads as a corrupt image.
The probes use pg_isready rather than a TCP check, because the port accepts connections before the cluster is ready to answer queries — and the backend's own readiness depends on the datasource, so a premature ready signal cascades.
Backend wiring
- name: DB_URL
value: "jdbc:postgresql://postgres:5432/mobilesigner"
- name: DB_USERNAME
valueFrom: { secretKeyRef: { name: mobilesigner-db, key: POSTGRES_USER } }
- name: DB_PASSWORD
valueFrom: { secretKeyRef: { name: mobilesigner-db, key: POSTGRES_PASSWORD } }
- name: SESSION_ENCRYPTION_KEY
valueFrom: { secretKeyRef: { name: mobilesigner-session, key: key } }
The session key is now load-bearing rather than merely good practice. With two replicas, a cookie minted by one pod must be decryptable by the other, so all replicas need the identical key:
kubectl -n mobilesigner-demo create secret generic mobilesigner-session \
--from-literal=key="$(openssl rand -base64 32)"
Rotating it invalidates every live session, which is the intended effect.
Scaling out
The backend runs replicas: 2 with a surge-only rollout:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
maxUnavailable: 0 means capacity never dips during a deploy, which is only safe because the readiness probe gates on the datasource actually being reachable.
Nothing else was needed to scale out. The application tier holds no per-request state — the WebAuthn challenge and the session both live in encrypted cookies — so the file-based database was the sole constraint. See Architecture for why, and for how the multi-replica seeding race is handled.
Hardening checklist
The manifests are sized for a local demo. For anything shared, add:
- Managed PostgreSQL, or at least backups. A single
StatefulSetreplica with aReadWriteOncevolume is a single point of failure; there is no replication and no scheduled dump. - Flyway. Hibernate's
schema-management.strategy=updateis additive only and will not reconcile a renamed column or a changed type. - A real secret store.
k8s/postgres.yamlcontains astringDatapassword in the repository, which is fine for a local cluster and unacceptable anywhere else. - A
securityContexton the backend and frontend. Both still run as root with a writable root filesystem; the docs site already runs non-root and read-only. - A
NetworkPolicy. Any pod in the cluster can reach the backend on 8090 and PostgreSQL on 5432 directly. - TLS inside the cluster. Ingress-to-Service and backend-to-database are both plain traffic today.
- A
PodDisruptionBudgetand anti-affinity, now that more than one replica exists and they could both land on one node.
Deploying
# backend
cd mobilesigner-webauthn-backend-demo
docker build -t localhost:5002/mobilesigner-demo/backend:latest .
docker push localhost:5002/mobilesigner-demo/backend:latest
# frontend
cd ../mobilesigner-webauthn-next-demo
docker build -t localhost:5002/mobilesigner-demo/frontend:latest .
docker push localhost:5002/mobilesigner-demo/frontend:latest
kubectl apply -f k8s/deploy.yaml
kubectl -n mobilesigner-demo rollout status deploy/backend deploy/frontend
Push to localhost:5002, but the manifests pull from registry.localhost:5001 — the same registry under its in-cluster name.
Then open http://webauthn-demo.localhost.
Because :latest is reused, force a pull after a rebuild:
kubectl -n mobilesigner-demo rollout restart deploy/backend deploy/frontend
Remember that restarting the backend wipes the database and re-seeds, so any credential you enrolled is gone and you re-enrol.