Files
dezky/infrastructure/production/fleet
Ronni Baslund 1114be6c93
ci / typecheck (map[dir:apps/booking name:booking]) (push) Successful in 45s
ci / typecheck (map[dir:apps/operator name:operator]) (push) Successful in 50s
ci / build (map[dir:apps/operator name:operator]) (push) Failing after 5s
ci / deploy (push) Has been skipped
ci / typecheck (map[dir:apps/portal name:portal]) (push) Successful in 27s
ci / typecheck (map[dir:apps/website name:website]) (push) Successful in 23s
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Successful in 24s
ci / test (push) Successful in 35s
ci / build (map[dir:apps/booking name:booking]) (push) Failing after 7s
ci / build (map[dir:apps/portal name:portal]) (push) Failing after 5s
ci / build (map[dir:services/platform-api name:platform-api]) (push) Failing after 6s
fix(ci): expose the dind docker host to job containers
gitea/runner 1.x no longer auto-mounts the docker daemon into job
containers (act_runner 0.2.x did), so 'docker build' in the build jobs
failed with 'cannot connect to /var/run/docker.sock'. container.docker_host
"" restores find-and-mount.
2026-06-10 08:34:54 +02:00
..

dezky production — fleet (k3s app tier)

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.