← Lab · Case study

AssetCare: from an idea to a live Kubernetes deployment.

A complete asset-maintenance application, designed, built, containerised, secured and deployed by one engineer, running today at assetcare.janaka.me. Java 25 and Spring Boot 4.1 behind an Angular 22 front end, Keycloak for identity, PostgreSQL 18, S3-compatible object storage, all on K3s with Helm, Traefik and Let's Encrypt on one Hetzner Cloud server. AI-assisted engineering accelerated the implementation, the investigation and the documentation; the architecture, the security decisions, the validation and the production delivery stayed engineering-led.

Live · production demonstration Java 25 · Spring Boot 4.1 Angular 22 · TypeScript Kubernetes (K3s) · Helm Keycloak · PostgreSQL 18 AI-assisted engineering
1cloud server, Kubernetes on top
5workloads: SPA, API, PostgreSQL, Keycloak, MinIO
401for every anonymous API call
HTTPSLet's Encrypt via cert-manager

Why it exists

Not another CRUD demo. The whole production path, executed.

Most reference projects stop at "runs on my laptop". AssetCare was built to go the rest of the way: a real identity provider, migrations, container images that run as non-root, a Helm chart, a cloud server created from Terraform, Kubernetes, a certificate that renews itself, health probes, backups, and a public URL that a stranger can log into.

The domain is deliberately small. People own assets (a car, a boiler, a laptop), assets need maintenance on a schedule, and someone has to prove what was done and when. Small enough to hold in your head, rich enough to need every piece of a serious system.

What it demonstrates

  • Enterprise Java, current generation. Java 25, Spring Boot 4.1, Spring Security 7, Hibernate 7, Liquibase 5, clean architecture enforced by an ArchUnit test.
  • Cloud-native delivery. Multi-architecture images, a Helm chart, K3s, Traefik, cert-manager, Terraform, GitHub Actions with scans, SBOM and signed images.
  • Production discipline. Probes, secrets, rate limiting, security headers, audit trail, backup CronJob with a tested restore, a runbook written for the person on call.
  • AI as an accelerator, not a substitute. Coding assistants worked inside a repository with written rules, and every decision that constrains the system is an ADR a human signed off.

Live demo

Try it yourself.

Sign in with the public demo account

Open assetcare.janaka.me and use the shared demo account. It is deliberately public, holds only fictional sample data (a household and home office in Zug) and has the plain USER role.

Username: demo
Password: AssetCare-Demo-2026

Read/write demo environment, sample data only. Other visitors see and can change the same data; it is reseeded from time to time. No administrator, database or infrastructure credential is public anywhere.

Worth trying once you are in

  • The dashboard: status tiles, the agenda of overdue and upcoming maintenance.
  • Press Ctrl K (or ⌘ K) for the command menu: jump to a page, find an asset by name, tag or serial.
  • Open an asset in two tabs, save in both: the second save gets a conflict, not a silent overwrite (optimistic locking with If-Match).
  • Upload an attachment (image preview, SHA-256 recorded) and complete a maintenance: a service record and the next occurrence appear, and the History tab shows the audit trail.
  • Filter, sort and page the asset list, then copy the URL: the state travels with it. Export the list as CSV.
  • Switch to dark mode, narrow the window to phone width, go offline: the interface says so.

Architecture

One door, one writer, everything else replaceable.

Traefik is the only public entry point. The Angular SPA is static files behind nginx. The Spring Boot API is the only process that writes to the database. Identity is external. Attachments live in object storage. Every one of the stateful pieces can be swapped for a managed service by changing a Helm value, not code.

Runtime

Browser
  │  HTTPS (Let's Encrypt, renewed by cert-manager)
  ▼
Traefik ingress  (rate limit on /api, TLS termination)
  ├── /            → Angular 22 SPA  (nginx, strict CSP, non-root)
  ├── /api/v1/**   → Spring Boot 4.1 API on Java 25
  │                    ├── PostgreSQL 18   (Liquibase migrations, audit trail)
  │                    ├── Keycloak 26     (JWT verification, roles)
  │                    └── MinIO / S3      (attachments, SHA-256)
  ├── /auth/**     → Keycloak  (OpenID Connect, Authorization Code + PKCE)
  └── /actuator/health, /actuator/info  → API probes (nothing else exposed)

Infrastructure and delivery

Hetzner Cloud CX33 (4 vCPU, 8 GB, EU)
  → created by Terraform: server, cloud firewall (22 from one IP, 80, 443),
    cloud-init user, nightly server backups
  → Ubuntu 24.04
  → K3s (Kubernetes 1.37, single node, Traefik bundled)
  → cert-manager + ClusterIssuer (staging first, then production)
  → Helm release "assetcare": deployments, statefulsets, PVCs,
    ingress, secrets refs, backup CronJob

GitHub
  → pull request → ci (tests, Playwright, restore drill, helm lint,
    terraform validate, image build, Trivy)
  → security (CodeQL, Trivy, gitleaks, dependency review)
  → tag v1.0.0 → release (amd64 + arm64 images, SBOM, cosign
    signature, provenance, chart on the GitHub release)
  → ghcr.io images → helm upgrade → smoke test → health check

Each part, in one line

PartRoleWhy this choice
TraefikThe only public door: routes, terminates TLS, rate-limits /apiBundled with K3s; one IP, one certificate, several services
Angular 22 SPAStatic bundle on unprivileged nginx; runtime config.json rendered from the environmentOne image for every environment; strict Content-Security-Policy without inline scripts
Spring Boot 4.1 APIBusiness rules, authorization policy, audit events, Problem Details errors, Liquibase at start-upClean architecture with the dependency direction enforced by ArchUnit
PostgreSQL 18All business data, the audit trail and idempotency keys, on a persistent volumeManaged PostgreSQL or Oracle is a values change; the migration path is documented
Keycloak 26Login, roles USER / ADMIN / AUDITOR, JWT issuance; the application never sees a passwordStandard OIDC with PKCE; swapping in a corporate identity provider is configuration
MinIOS3-compatible storage for attachment bytes, outside the databaseThe API speaks S3, so any object storage replaces it
cert-managerAsks Let's Encrypt for the certificate and renews it before expiryHTTPS without a human in the loop
Helm chartOne command creates or upgrades every object; every environment difference is a valueRollback is helm rollback; enterprise switches for external DB, IdP, storage and vault secrets
TerraformThe server, its firewall and backups as codeThe VM can be destroyed and recreated in a minute from the repository

Technology stack

Current versions, checked against their release pages.

Backend

Java 25 (LTS)Spring Boot 4.1Spring Security 7Spring DataHibernate 7Liquibase 5PostgreSQL 18MicrometerOpenTelemetryspringdoc-openapi

Tests: JUnit 6, AssertJ, Mockito, Testcontainers, ArchUnit. Build: Maven wrapper.

Frontend

Angular 22TypeScript 6Angular Materialangular-oauth2-oidc (PKCE)VitestPlaywrightESLint · Prettiernginx (unprivileged)

One set of design tokens, dark mode, phone layout, command menu, skeleton loading, accessibility lint rules.

Platform and delivery

Docker (multi-arch)Kubernetes · K3s 1.37Helm 4Traefikcert-managerLet's EncryptKeycloak 26MinIOTerraform · Hetzner CloudGitHub ActionsTrivy · CodeQL · gitleakscosign · CycloneDX SBOM

Observability profile: Prometheus, Alertmanager, Grafana, Loki, Tempo, OpenTelemetry Collector (see below for its status).

Security

The backend is the authority. The UI only shapes what it shows.

Identity and authorization

  • Keycloak issues OpenID Connect tokens; the SPA uses Authorization Code with PKCE; the API is a resource server that validates every JWT.
  • Every endpoint requires a valid token. Anonymous calls get 401, verified on the live host on 23 September 2026.
  • Roles from the token: USER sees own assets, AUDITOR reads everything and writes nothing, ADMIN manages configuration and can restore or hard-delete. One AuthorizationPolicy decides; controllers never check roles.
  • Ownership is checked per aggregate: 404 for what you may not see, 403 for what you may see but not change.

Edges and supply chain

  • The live front end sends a strict Content-Security-Policy, X-Frame-Options: DENY, nosniff, a Referrer-Policy and a Permissions-Policy (observed in the response headers).
  • Rate limiting on /api at the Traefik ingress; the API answers 429 with Problem Details if a per-user limit is hit.
  • Errors are RFC 9457 Problem Details. Updates need If-Match (428 without it, 409 when stale). Creates accept an Idempotency-Key.
  • Uploads: content-type allowlist, size limit, sanitised filename, bytes stored outside the database under a generated key, SHA-256 recorded.
  • Images run as non-root with all capabilities dropped; CI scans dependencies and images with Trivy and CodeQL, runs gitleaks over the full history, and the release is signed with cosign and carries a CycloneDX SBOM.
  • No secret in Git. Production secrets are Kubernetes Secrets created once on the cluster; the development credentials in the repository are labelled as such and were rotated on the cluster before the demo went public.

Testing and CI/CD

Nothing reaches main untested. Nothing reaches the cluster unsigned.

ci, on every push

  • Backend unit and ArchUnit tests, then integration tests against PostgreSQL 18 in Testcontainers.
  • Frontend lint, format check, Vitest unit tests, production build under budget.
  • Playwright end to end against the production frontend image with a real Keycloak and API; the same smoke test later runs against the deployment.
  • A backup, damage, restore drill: dump, break the database, restore, compare row counts.
  • Helm lint and template, the observability compose file, Terraform format and validate; image builds with a Trivy gate.

security, weekly and on change

  • CodeQL for Java and TypeScript.
  • Trivy on dependencies and configuration.
  • gitleaks over the full history.
  • Dependency review on pull requests; Dependabot keeps images and libraries current.

release, on a version tag

  • Native amd64 and arm64 builds, one manifest per image, pushed to GHCR.
  • Scan, SBOM, keyless cosign signature and build provenance.
  • The packaged Helm chart attached to the GitHub release. v1.0.0 shipped this way on 23 September 2026.

Infrastructure and operations

Rebuildable from files in the repository.

Infrastructure as code

  • Terraform (provider hetznercloud/hcloud) creates the CX33, a stateful cloud firewall that admits SSH from the operator's IP and HTTP/HTTPS from anywhere, a cloud-init ubuntu user with root login disabled, and nightly Hetzner server backups. The API token lives only in an environment variable.
  • K3s installed by one script; cert-manager with a staging issuer first, then production; DNS pointed at the server; the certificate went Ready and the padlock appeared.
  • Helm installs the release with production values and an existingSecret; the chart's probes, resource requests and limits keep the whole stack inside 8 GB with headroom.
  • Why Hetzner and not a free tier: the earlier Oracle Cloud Always Free attempt could not be created on demand. ADR-012 records the decision and its cost.

Day two

  • Health. Liveness and readiness probes drive restarts and traffic; only /actuator/health and /actuator/info are exposed through the ingress. Metrics and everything else stay inside the cluster. A one-command health script prints OK / WARN / FAIL for pods, probes, certificate, backup age and the HTTP checks, naming the runbook section for anything wrong.
  • Backups. A CronJob runs a compressed, checksummed pg_dump nightly at 02:30 UTC, keeps fourteen, and can upload to any S3-compatible bucket; Hetzner server backups give an off-VM copy. The restore is a script that CI rehearses on every run. Recovery objective of the reference deployment: 24 hours.
  • Runbook. Symptom, check, action, confirmation, including "the VM is gone": Terraform, install script, chart, latest dump, DNS, new certificate, under an hour once the VM exists.
  • Demo data. Seeded through the real API by a script, so every write is authorized, validated and audited like a user's; reset by giving the demo account a fresh identity.

Observability

Instrumented in production. The full stack is a profile, not yet deployed.

Running on the live server

  • Structured JSON logs where every line carries requestId, traceId and spanId; a request id enters with or comes back in the response.
  • Micrometer metrics (HTTP, JVM, GC, HikariCP, cache, business counters) on the internal /actuator/prometheus endpoint, not exposed publicly.
  • OpenTelemetry tracing instrumented through spring-boot-starter-opentelemetry; telemetry is exported over OTLP only when a collector is present.
  • Business audit events in the database, each with its request and trace id.
  • Liveness, readiness and dependency health groups, verified from outside.

Available as an observability profile

Prometheus, Alertmanager, the blackbox exporter, Grafana (a 22-panel dashboard in Git), Loki, Tempo and the OpenTelemetry Collector, with thirteen alert rules that each link to a runbook section. The profile starts locally with make observability and has Helm values for the cluster.

Status, honestly: it is not running on the production server. The 8 GB CX33 holds the core chart with headroom; the observability profile needs a larger node or a second server, which the project's production-gaps document records. It is architecture and configuration ready to enable, not a monitoring capability currently in operation.

The engineering journey

From a prototype to a public URL, in the order it happened.

  • 01 · Architecture and standards

    Decisions before code

    version matrix · ADRs · definition of done · clean architecture

    • Every version checked against its official release page. Eleven ADRs for the choices that constrain the future, from PostgreSQL to "archive instead of delete".
    • The dependency direction written as an ArchUnit test, so it is enforced rather than described.
  • 02 · Local environment

    Docker Compose for the stateful parts

    PostgreSQL 18 · Keycloak 26 with the realm · MinIO

    • The same images as production, in one command, so every laptop and CI run the same versions.
  • 03 · Data and migrations

    Liquibase changesets with rollbacks

    Hibernate validates the schema; nothing is created by the ORM

    • Audit columns and optimistic-locking versions on every entity; a full audit event for every write, in the same transaction.
  • 04 · Authentication

    Keycloak, OIDC with PKCE, a resource server

    roles USER · ADMIN · AUDITOR

    • The application never sees a password. One authorization policy, tested, decides what each role may do.
  • 05 · Backend and frontend

    The API and the Angular application

    Problem Details · If-Match · Idempotency-Key · CSV export · attachments · command menu

    • Search, filters, sort and paging in the URL; conflict detection; drafts and unsaved-change protection; dark mode and phone layout.
  • 06 · Testing and CI

    Tests at every level, then a machine that runs them

    unit · ArchUnit · Testcontainers · Vitest · Playwright · restore drill

    • Rehearsing the CI jobs locally found a critical Tomcat CVE, invalid Terraform, missing security headers and a production build blocked by its own CSP, before the first GitHub run.
  • 07 · Containers and registry

    Multi-arch, non-root images on GHCR

    amd64 + arm64 · Trivy · SBOM · cosign · provenance

    • v1.0.0 released with signed images and the packaged chart; cosign verify passes.
  • 08 · Infrastructure

    Terraform on Hetzner Cloud

    CX33 · cloud firewall · cloud-init · server backups

    • After the free Oracle Cloud path failed to produce a server on demand, the trial moved to a small paid VM. The chart, installer and images were provider-neutral, so it was a change of VM, not of architecture.
  • 09 · Kubernetes and Helm

    K3s, cert-manager, the release

    one node · Traefik bundled · probes · resource limits · secrets created on the cluster

    • The installer learned to open ports 80 and 443 only when the image's firewall rejects them, a difference between Oracle's and Hetzner's Ubuntu images found during the install.
  • 10 · HTTPS

    DNS, staging issuer, production issuer

    Let's Encrypt · HTTP-01 through Traefik · certificate issued 23 September 2026

    • Staging first, so a mistake does not burn the rate limit; then production, then the padlock.
  • 11 · Health verification

    Probes, smoke test, login

    liveness UP · readiness UP · database reachable · anonymous 401 · Keycloak discovery 200

    • The same checks are repeated below, as run from outside the server.
  • 12 · Live

    assetcare.janaka.me, with a public demo account

    showcase data seeded through the API · development passwords rotated on the cluster

    • A stranger can log in, change data, upload a file and read the audit trail of what they did.

Verified

What was checked, and when.

Checked from outside the server on 23 September 2026. The repository's own verification report follows the same rule: a check is marked as passed only when it was run.

CheckResult
GET https://assetcare.janaka.me/200, valid certificate, issuer Let's Encrypt, subject assetcare.janaka.me
Response headers on the SPAContent-Security-Policy, X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy, Permissions-Policy, Cache-Control no-store on the shell
GET /api/v1/assets without a token401 Unauthorized
GET /actuator/health{"status":"UP"} with the groups dependencies, liveness, readiness
/actuator/health/liveness, /readiness200, UP, UP
/actuator/infoartifact assetcare-api, version 1.0.0, built 23 September 2026
/auth/realms/assetcare/.well-known/openid-configuration200, Keycloak serving the realm behind the same host
/actuator/prometheus, /grafana/not routed: the SPA shell answers, as the ingress intends
GitHub Actionsci, security and release workflows green on 23 September 2026 (run ids in the repository's path-to-production document)

AI-assisted engineering

AI increased the velocity. Engineering discipline made it production-worthy.

This project was also an experiment in how far AI-assisted engineering can accelerate a senior engineer without removing the discipline. The answer was: a long way on implementation, investigation and documentation, and not at all on the decisions that decide whether a system deserves to be in production.

What the assistant accelerated

  • Architectural exploration. Options for identity, persistence, state management and object storage drafted against a model, then decided and recorded as ADRs.
  • Code generation inside written rules. The repository's CLAUDE.md states the non-negotiables: dependency direction, migrations only as changesets, archive instead of delete, an audit event for every write, Problem Details, authorization as a policy. Project skills (/add-feature, /add-migration, /write-adr, /architecture-review, /operate, /release) encode the playbook the assistant follows.
  • Debugging and migration analysis. Spring Boot 4.1 start-up failures (ambiguous CORS and upload-size beans), a lazy-association failure on list endpoints, an inject() after await that froze the SPA at "Loading", Keycloak's iss parameter left in the URL.
  • Kubernetes and deployment investigation. The iptables rule position that failed on Hetzner's image, a MinIO image tag that could not be pulled, a race in docker compose up --wait, a backups volume that had to be bound at install for helm --wait to succeed.
  • Tests, documentation and operations text. Test scaffolds, the setup guide written for a beginner, the runbook, the health script, dashboards and alert rules.

What stayed with the engineer

  • Architecture and trade-offs. One modular service before microservices, PostgreSQL before Oracle, Keycloak, K3s on one small server and the documented seams to an enterprise platform.
  • Security. The threat model, the authorization model, the header policy, the decision to pin Tomcat for three critical CVEs that the Spring Boot BOM did not yet manage, the rotation of the development passwords before the demo went public.
  • Validation. Every check in the verification report was run, and anything not run is marked as not executed. A reference nobody has run is a hypothesis.
  • Production decisions and delivery. Moving off the free tier that could not deliver a VM, when to tag v1.0.0, what to expose through the ingress, what a public demo account may and may not do.
  • Verification. Reading the generated code, running the tests, watching the certificate go Ready, checking the 401 from outside.

How this workflow generalises, on the AI page →

What it is not

A production-style reference deployment, not an enterprise platform.

The point of the project is the honest boundary. The application code is written so that an enterprise replaces the pieces on the right without a rewrite; the reference deployment deliberately stops short of them.

  • One node, one disk. A VM failure is an outage. No pod disruption budgets, no multi-zone, no autoscaling turned on.
  • Keycloak runs in development mode in the chart, with its realm imported from Git. Production mode on PostgreSQL is an open item.
  • Secrets are Kubernetes Secrets, created once on the cluster, not sourced from a vault.
  • The observability profile is not deployed on the 8 GB server (above).
  • Container root filesystems are writable; the Security tab reports it and the fix is documented.
  • No customers, no user numbers, no availability figure. It is a demonstration with a shared demo account and sample data, and it is described as such everywhere.

The full production-gaps table in the repository ↗

Go deeper

The same project, from four angles.

Blog · engineering article

From idea to production

What broke on the way to a public URL, what AI accelerated and what still needed judgement.

Academy · course

From CRUD to production

The seven-course walkthrough of the repository, with an interactive board that assembles the system step by step.

AI · practice

AI-assisted engineering in practice

The workflow behind this and the five products: what the model does, what the engineer keeps.

This is the standard I bring to a team.

Twenty-two years of enterprise Java, the current cloud-native stack, and AI used to move faster without lowering the bar. If that is the problem on your desk, my inbox is open.