Files
dezky/infrastructure/production/fleet
Ronni Baslund a43a172449
ci / changes (push) Successful in 4s
ci / tc_portal (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / test_platform_api (push) Successful in 34s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 23s
ci / build_portal (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / build_platform_api (push) Successful in 18s
ci / deploy (push) Successful in 41s
feat(domains): reserve the platform namespace + one workspace per domain
dezky.eu doubles as the platform's infrastructure domain AND the company's
own employee mail domain (added to the dezky tenant via the normal Domains
flow). Guard rails in DomainsService.add:
- a domain already used by ANY other workspace is rejected — Stalwart's
  idempotent ensureDomain would otherwise silently share one mail domain
  (and its mailboxes) between tenants
- the PLATFORM_TENANT_DOMAIN apex is claimable only by the dezky tenant;
  everything under it (per-tenant service domains, auth/api/mail/* infra
  hosts) is reserved outright

Set PLATFORM_TENANT_DOMAIN=dezky.eu in the prod ConfigMap (was unset, so
prod service domains would have been {slug}.dezky.local) and align the
seeded dezky tenant's display domain with the environment.
2026-06-10 20:15:46 +02:00
..

dezky production — fleet (k3s app tier)

Pipeline verified end-to-end 2026-06-10: push to main → build → deploy.

k3s manifests for the dezky application tier that runs in-cluster on the Hetzner AX41 node (see ../host/README.md for the host layer). This layer deploys the first-party apps:

App Image Public host Internal Service
platform-api git.lastcloud.io/ronnibaslund/dezky/platform-api api.dezky.eu platform-api.dezky-apps:3001
portal git.lastcloud.io/ronnibaslund/dezky/portal app.dezky.eu portal.dezky-apps:3000
booking git.lastcloud.io/ronnibaslund/dezky/booking booking.dezky.eu booking.dezky-apps:3000
operator git.lastcloud.io/ronnibaslund/dezky/operator operator.dezky.eu operator.dezky-apps:3000

All of them live in the dezky-apps namespace. The data tier (Postgres/Mongo/ Redis), Authentik and OCIS are added by other parts of the fleet layer and live in their own namespaces; these manifests reference them by cluster DNS only.

Files

apps/
├── kustomization.yaml          # bundles the non-secret resources
├── namespace.yaml              # dezky-apps namespace
├── redirect-middleware.yaml    # per-router HTTP→HTTPS redirect (ACME-safe)
├── platform-api.yaml           # Deployment + Service + Ingress (api.dezky.eu)
├── platform-api-config.yaml    # non-secret ConfigMap (Stalwart URL, toggles)
├── portal.yaml                 # Deployment + Service + Ingress (app.dezky.eu)
├── booking.yaml                # Deployment + Service + Ingress (booking.dezky.eu)
├── operator.yaml               # Deployment + Service + Ingress (operator.dezky.eu)
└── secrets.example.yaml        # SECRET TEMPLATE — never commit real values
ci/
├── gitea-runner.yaml           # in-cluster Gitea Actions runner (+ dind)
└── ci-deployer.yaml            # ServiceAccount/RBAC the CI deploy job uses

Prerequisites (other fleet layers)

These manifests assume the cluster already has:

  • Traefik ingress controller (ships with k3s) — ingressClassName: traefik.
  • cert-manager with a ClusterIssuer named letsencrypt-prod (HTTP-01). The Ingresses request TLS certs via the cert-manager.io/cluster-issuer annotation; cert-manager fills the named *-tls Secrets.
  • MongoDB reachable at mongo.dezky-data.svc.cluster.local:27017.
  • Authentik reachable publicly at https://auth.dezky.eu with OIDC clients provisioned for the portal.
  • Stalwart on the host, reachable from the pod CIDR at its node-internal IP on :8080 (JMAP management). Update the placeholder IP in platform-api-config.yaml.

DNS

Point these A/AAAA records at the AX41 public IP:

api.dezky.eu       → <AX41 IP>
app.dezky.eu       → <AX41 IP>
booking.dezky.eu   → <AX41 IP>

Deploy

Push to main = release. CI (.gitea/workflows/ci.yml, runner in-cluster — see ci/gitea-runner.yaml) typechecks + tests, builds each app image tagged :latest and the commit SHA, pushes to the Gitea registry, then the deploy job pins the kustomization to that SHA (kustomize edit set image), runs kubectl apply -k apps/ and waits for the rollouts. No GitOps controller, no bot commits — the pipeline that built the image deploys it.

One-time bootstrap for the deploy job's cluster access:

# 1) ServiceAccount + RBAC (admin scoped to dezky-apps).
kubectl apply -f ci/ci-deployer.yaml

# 2) Mint a kubeconfig from its token and store it as the Gitea repo secret
#    KUBECONFIG_B64 (repo Settings → Actions → Secrets). API server address is
#    the in-cluster service IP — the runner's jobs run inside the cluster.
TOKEN=$(kubectl -n dezky-apps get secret ci-deployer-token -o jsonpath='{.data.token}' | base64 -d)
CA=$(kubectl -n dezky-apps get secret ci-deployer-token -o jsonpath='{.data.ca\.crt}')
cat <<EOF | base64 | pbcopy   # paste into the KUBECONFIG_B64 secret
apiVersion: v1
kind: Config
clusters:
  - name: dezky
    cluster:
      server: https://10.43.0.1:443
      certificate-authority-data: $CA
users:
  - name: ci-deployer
    user:
      token: $TOKEN
contexts:
  - name: dezky
    context: {cluster: dezky, user: ci-deployer, namespace: dezky-apps}
current-context: dezky
EOF

Manual / break-glass deploy (first boot, runner down, secrets changed):

# Real Secrets are applied out-of-band (NOT from git) and the Deployments
# won't start without them. Copy the template, fill in values, apply.
cp apps/secrets.example.yaml /tmp/dezky-secrets.yaml
$EDITOR /tmp/dezky-secrets.yaml          # fill every REPLACE / PASSWORD
kubectl apply -f /tmp/dezky-secrets.yaml
rm /tmp/dezky-secrets.yaml

kubectl apply -k apps/
kubectl -n dezky-apps rollout status deploy/platform-api
kubectl -n dezky-apps get ingress,certificate

Required env / secrets

Non-secret config lives in platform-api-config.yaml (ConfigMap) and inline env: in each Deployment. Secrets are defined in secrets.example.yaml and must be supplied at deploy time:

platform-api (platform-api-secrets)

Key Purpose How to get it
MONGODB_URI Portal/app database connection From the in-cluster Mongo credentials
SCHEDULING_CREDENTIAL_KEY AES key encrypting stored scheduling creds openssl rand -hex 32 — back up
STALWART_ADMIN_PASSWORD JMAP management auth Same value as the host config.env
STALWART_WEBHOOK_SECRET Audit webhook HMAC Same value as the host config.env
AUTHENTIK_API_TOKEN Authentik admin API (provisioning) dezky-auth/authentik-secret.AUTHENTIK_BOOTSTRAP_TOKEN
AUDIT_SIGNING_KEY Audit hash-chain signing key openssl rand -hex 32 — back up
AUDIT_COLD_ACCESS_KEY / AUDIT_COLD_SECRET_KEY Hetzner Object Storage IAM placeholder until the bucket exists
OCIS_SVC_PASSWORD OCIS service user (files tier) placeholder until OCIS is deployed

Non-secret runtime config (Authentik issuer/audience/JWKS, Stalwart URL, OCIS URL, cold-storage endpoint, feature toggles) lives in platform-api-config.yaml. PORT and DEZKY_ENV are set inline.

portal (portal-secrets)

Key Purpose
NUXT_OIDC_CLIENT_ID / NUXT_OIDC_CLIENT_SECRET Authentik OIDC client
NUXT_OIDC_REDIRECT_URI https://app.dezky.eu/auth/callback
NUXT_OIDC_SESSION_SECRET Session encryption (openssl rand -hex 32)
NUXT_PUBLIC_AUTH_URL Public Authentik URL (login + full sign-out)

PLATFORM_API_INTERNAL_URL / NUXT_API_BASE / NUXT_PUBLIC_PORTAL_URL are set inline in portal.yaml.

booking (booking-secrets)

Key Purpose
NUXT_PUBLIC_TURNSTILE_SITE_KEY Cloudflare Turnstile site key (public, env-injected)

PLATFORM_API_INTERNAL_URL / NUXT_PUBLIC_SITE_URL are set inline in booking.yaml.

Never commit real secret values. secrets.example.yaml is a template and is deliberately excluded from kustomization.yaml. Manage real secrets via sealed-secrets / SOPS / the Rancher secret store.