feat(ci): deploy to k3s straight from the pipeline (drop Flux plan)
ci / build (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / build (map[dir:apps/operator name:operator]) (push) Has been cancelled
ci / build (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / build (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / deploy (push) Has been cancelled
ci / typecheck (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / typecheck (map[dir:apps/operator name:operator]) (push) Has been cancelled
ci / typecheck (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / typecheck (map[dir:apps/website name:website]) (push) Has been cancelled
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / test (push) Has been cancelled

Push to main = release: after build, a deploy job pins each app image to the
commit SHA (kustomize edit set image), kubectl-applies fleet/apps and waits
for the rollouts. The runner already runs in-cluster, so it reaches the API
server on the in-cluster service IP with a kubeconfig for the new ci-deployer
ServiceAccount (namespace-scoped admin, KUBECONFIG_B64 repo secret).

The drafted Flux sync/image-automation layer is removed — a GitOps controller
plus bot tag-bump commits is more machinery than a single-node cluster needs.
Sortable image tags and $imagepolicy markers go with it.

Also: per-router ACME-safe HTTP->HTTPS redirects for the app ingresses,
platform-api prod config completed (Authentik JWT/JWKS + admin API, Stalwart
via the cni0 gateway IP, OCIS/cold-storage placeholders until those tiers
exist) and the secrets template/README updated to match.
This commit is contained in:
Ronni Baslund
2026-06-10 07:53:55 +02:00
parent 52e0f5e375
commit c60937c5cb
11 changed files with 300 additions and 36 deletions
+44 -1
View File
@@ -19,6 +19,7 @@ jobs:
- { name: portal, dir: apps/portal } - { name: portal, dir: apps/portal }
- { name: booking, dir: apps/booking } - { name: booking, dir: apps/booking }
- { name: website, dir: apps/website } - { name: website, dir: apps/website }
- { name: operator, dir: apps/operator }
defaults: defaults:
run: run:
working-directory: ${{ matrix.target.dir }} working-directory: ${{ matrix.target.dir }}
@@ -65,6 +66,7 @@ jobs:
- { name: portal, dir: apps/portal } - { name: portal, dir: apps/portal }
- { name: booking, dir: apps/booking } - { name: booking, dir: apps/booking }
- { name: platform-api, dir: services/platform-api } - { name: platform-api, dir: services/platform-api }
- { name: operator, dir: apps/operator }
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Registry login - name: Registry login
@@ -72,6 +74,47 @@ jobs:
- name: Build + push - name: Build + push
run: | run: |
IMG=git.lastcloud.io/ronnibaslund/dezky/${{ matrix.app.name }} IMG=git.lastcloud.io/ronnibaslund/dezky/${{ matrix.app.name }}
docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" "${{ matrix.app.dir }}" # The commit SHA tag is what the deploy job pins the cluster to;
# ':latest' is kept for humans / manual pulls only.
docker build \
-t "$IMG:latest" \
-t "$IMG:${{ github.sha }}" \
"${{ matrix.app.dir }}"
docker push "$IMG:latest" docker push "$IMG:latest"
docker push "$IMG:${{ github.sha }}" docker push "$IMG:${{ github.sha }}"
# Deploy the freshly built images to the k3s cluster the runner already runs
# in. No GitOps controller in between: kustomize pins each Deployment to this
# commit's SHA tag and kubectl applies the manifests, so "push to main" IS
# the release. Auth is the KUBECONFIG_B64 repo secret — a kubeconfig for the
# ci-deployer ServiceAccount (see infrastructure/production/fleet/ci/
# ci-deployer.yaml), reaching the API server on the in-cluster service IP.
deploy:
runs-on: ubuntu-latest
needs: [build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Install kubectl + kustomize
run: |
curl -fsSLo /usr/local/bin/kubectl https://dl.k8s.io/release/v1.33.4/bin/linux/amd64/kubectl
chmod +x /usr/local/bin/kubectl
curl -fsSL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.6.0/kustomize_v5.6.0_linux_amd64.tar.gz \
| tar -xz -C /usr/local/bin kustomize
- name: Deploy to k3s
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
run: |
export KUBECONFIG=/tmp/kubeconfig
echo "$KUBECONFIG_B64" | base64 -d > "$KUBECONFIG"
cd infrastructure/production/fleet/apps
for app in platform-api portal booking operator; do
kustomize edit set image \
"git.lastcloud.io/ronnibaslund/dezky/$app=git.lastcloud.io/ronnibaslund/dezky/$app:${{ github.sha }}"
done
kubectl apply -k .
for app in platform-api portal booking operator; do
kubectl -n dezky-apps rollout status "deploy/$app" --timeout=180s
done
+57 -14
View File
@@ -2,15 +2,16 @@
k3s manifests for the dezky **application tier** that runs in-cluster on the 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 Hetzner AX41 node (see `../host/README.md` for the host layer). This layer
deploys the three first-party apps: deploys the first-party apps:
| App | Image | Public host | Internal Service | | App | Image | Public host | Internal Service |
|-----|-------|-------------|------------------| |-----|-------|-------------|------------------|
| platform-api | `git.lastcloud.io/ronnibaslund/dezky/platform-api` | `api.dezky.eu` | `platform-api.dezky-apps:3001` | | 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` | | 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` | | 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 three live in the `dezky-apps` namespace. The data tier (Postgres/Mongo/ 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 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. in their own namespaces; these manifests reference them by cluster DNS only.
@@ -20,11 +21,16 @@ in their own namespaces; these manifests reference them by cluster DNS only.
apps/ apps/
├── kustomization.yaml # bundles the non-secret resources ├── kustomization.yaml # bundles the non-secret resources
├── namespace.yaml # dezky-apps namespace ├── 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.yaml # Deployment + Service + Ingress (api.dezky.eu)
├── platform-api-config.yaml # non-secret ConfigMap (Stalwart URL, toggles) ├── platform-api-config.yaml # non-secret ConfigMap (Stalwart URL, toggles)
├── portal.yaml # Deployment + Service + Ingress (app.dezky.eu) ├── portal.yaml # Deployment + Service + Ingress (app.dezky.eu)
├── booking.yaml # Deployment + Service + Ingress (booking.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 └── 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) ## Prerequisites (other fleet layers)
@@ -54,26 +60,58 @@ booking.dezky.eu → <AX41 IP>
## Deploy ## 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:
```bash ```bash
# 1) Apply real Secrets out-of-band (NOT from git). Copy the template, # 1) ServiceAccount + RBAC (admin scoped to dezky-apps).
# fill in values, apply — or render SealedSecrets from it. 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):
```bash
# 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 cp apps/secrets.example.yaml /tmp/dezky-secrets.yaml
$EDITOR /tmp/dezky-secrets.yaml # fill every REPLACE / PASSWORD $EDITOR /tmp/dezky-secrets.yaml # fill every REPLACE / PASSWORD
kubectl apply -f /tmp/dezky-secrets.yaml kubectl apply -f /tmp/dezky-secrets.yaml
rm /tmp/dezky-secrets.yaml rm /tmp/dezky-secrets.yaml
# 2) Apply the app tier.
kubectl apply -k apps/ kubectl apply -k apps/
# 3) Watch rollout + cert issuance.
kubectl -n dezky-apps rollout status deploy/platform-api kubectl -n dezky-apps rollout status deploy/platform-api
kubectl -n dezky-apps get ingress,certificate kubectl -n dezky-apps get ingress,certificate
``` ```
Images are pushed by CI (`.gitea/workflows/ci.yml`) to the Gitea registry. The
manifests reference `:latest` for convenience; for real releases, set the image
tag to the commit SHA and bump it per deploy (or wire Fleet/ArgoCD to do it).
## Required env / secrets ## Required env / secrets
Non-secret config lives in `platform-api-config.yaml` (ConfigMap) and inline Non-secret config lives in `platform-api-config.yaml` (ConfigMap) and inline
@@ -85,12 +123,17 @@ Non-secret config lives in `platform-api-config.yaml` (ConfigMap) and inline
| Key | Purpose | How to get it | | Key | Purpose | How to get it |
|-----|---------|---------------| |-----|---------|---------------|
| `MONGODB_URI` | Portal/app database connection | From the in-cluster Mongo credentials | | `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` | | `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_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` | | `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 |
ConfigMap (`platform-api-config`): `STALWART_API_URL`, `STALWART_ADMIN_USER`, Non-secret runtime config (Authentik issuer/audience/JWKS, Stalwart URL, OCIS
`STALWART_PROVISIONING_ENABLED`. `PORT` and `DEZKY_ENV` are set inline. URL, cold-storage endpoint, feature toggles) lives in
`platform-api-config.yaml`. `PORT` and `DEZKY_ENV` are set inline.
### portal (`portal-secrets`) ### portal (`portal-secrets`)
@@ -20,6 +20,8 @@ spec:
spec: spec:
containers: containers:
- name: booking - name: booking
# CI pins this to the commit SHA at deploy time (kustomize edit set image
# in .gitea/workflows/ci.yml); :latest here is the fallback.
image: git.lastcloud.io/ronnibaslund/dezky/booking:latest image: git.lastcloud.io/ronnibaslund/dezky/booking:latest
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
@@ -78,7 +80,10 @@ metadata:
namespace: dezky-apps namespace: dezky-apps
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure # Serve on :80 too so the cert-manager ACME HTTP-01 solver can answer on
# port 80; the redirect-https middleware bounces all other traffic to HTTPS.
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.middlewares: dezky-apps-redirect-https@kubernetescrd
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls: tls:
@@ -6,7 +6,9 @@ kind: Kustomization
namespace: dezky-apps namespace: dezky-apps
resources: resources:
- namespace.yaml - namespace.yaml
- redirect-middleware.yaml
- platform-api-config.yaml - platform-api-config.yaml
- platform-api.yaml - platform-api.yaml
- portal.yaml - portal.yaml
- booking.yaml - booking.yaml
- operator.yaml
@@ -9,9 +9,33 @@ metadata:
labels: labels:
app.kubernetes.io/name: platform-api app.kubernetes.io/name: platform-api
data: data:
# Stalwart runs on the HOST (not k3s). Reach it on the node-internal IP at the # Stalwart runs on the HOST (not k3s). Pods reach it via the cni0 gateway IP
# JMAP management port; the firewall lets the pod CIDR through. Override the # on the JMAP management port; the firewall lets the pod CIDR through.
# placeholder IP to match the host's actual internal address. STALWART_API_URL: "http://10.42.0.1:8080"
STALWART_API_URL: "http://10.0.0.1:8080"
STALWART_ADMIN_USER: "admin" STALWART_ADMIN_USER: "admin"
STALWART_PROVISIONING_ENABLED: "true" STALWART_PROVISIONING_ENABLED: "true"
# JWT validation for portal/operator-issued access tokens. Public Authentik
# URLs on purpose: the token `iss` claim is the public URL, and the pod can
# hairpin to it through the node's public IP.
AUTHENTIK_ISSUER: "https://auth.dezky.eu/application/o/dezky-portal/,https://auth.dezky.eu/application/o/dezky-operator/"
AUTHENTIK_AUDIENCE: "dezky-portal,dezky-operator"
AUTHENTIK_JWKS_URI: "https://auth.dezky.eu/application/o/dezky-portal/jwks/"
AUTHENTIK_API_URL: "https://auth.dezky.eu/api/v3"
# OCIS is not deployed in production yet. The client is instantiated at boot
# (so the URL must exist) but only fails when a files feature is actually
# used. Swap to the real URL when the files tier lands.
OCIS_API_URL: "https://files.dezky.eu"
OCIS_OIDC_TOKEN_URL: "https://auth.dezky.eu/application/o/token/"
OCIS_OIDC_CLIENT_ID: "ocis-web"
OCIS_SVC_USERNAME: "svc-platform-api"
# Audit cold storage (Hetzner Object Storage) is not provisioned yet —
# archive stays off; the S3 client boots against the placeholder endpoint.
AUDIT_COLD_ENDPOINT: "https://fsn1.your-objectstorage.com"
AUDIT_COLD_REGION: "fsn1"
AUDIT_COLD_BUCKET: "dezky-audit"
AUDIT_HOT_RETENTION_DAYS: "90"
ARCHIVE_ENABLED: "false"
# Stripe billing dark-launched off in prod until live keys are wired.
BILLING_STRIPE_ENABLED: "false"
BOOKING_PUBLIC_URL: "https://booking.dezky.eu"
MEET_PUBLIC_URL: "https://meet.dezky.eu"
@@ -21,7 +21,8 @@ spec:
spec: spec:
containers: containers:
- name: platform-api - name: platform-api
# Pinned by CI to the commit SHA on release; :latest is dev-only. # CI pins this to the commit SHA at deploy time (kustomize edit set image
# in .gitea/workflows/ci.yml); :latest here is the fallback.
image: git.lastcloud.io/ronnibaslund/dezky/platform-api:latest image: git.lastcloud.io/ronnibaslund/dezky/platform-api:latest
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
@@ -21,6 +21,8 @@ spec:
spec: spec:
containers: containers:
- name: portal - name: portal
# CI pins this to the commit SHA at deploy time (kustomize edit set image
# in .gitea/workflows/ci.yml); :latest here is the fallback.
image: git.lastcloud.io/ronnibaslund/dezky/portal:latest image: git.lastcloud.io/ronnibaslund/dezky/portal:latest
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
@@ -33,6 +35,8 @@ spec:
value: "3000" value: "3000"
- name: NUXT_PUBLIC_PORTAL_URL - name: NUXT_PUBLIC_PORTAL_URL
value: https://app.dezky.eu value: https://app.dezky.eu
- name: NUXT_PUBLIC_BOOKING_URL
value: https://booking.dezky.eu
# Cluster-internal address of platform-api for the nitro proxy. # Cluster-internal address of platform-api for the nitro proxy.
- name: PLATFORM_API_INTERNAL_URL - name: PLATFORM_API_INTERNAL_URL
value: http://platform-api.dezky-apps.svc.cluster.local:3001 value: http://platform-api.dezky-apps.svc.cluster.local:3001
@@ -81,7 +85,10 @@ metadata:
namespace: dezky-apps namespace: dezky-apps
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure # Serve on :80 too so the cert-manager ACME HTTP-01 solver can answer on
# port 80; the redirect-https middleware bounces all other traffic to HTTPS.
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.middlewares: dezky-apps-redirect-https@kubernetescrd
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls: tls:
@@ -0,0 +1,19 @@
# HTTP→HTTPS redirect for the dezky-apps ingresses (portal, booking).
#
# Replaces the former global Traefik entrypoint redirect, which broke ACME
# HTTP-01 (see traefik/helmchartconfig.yaml). This Middleware is attached only
# to the app routers via the
# `traefik.ingress.kubernetes.io/router.middlewares` annotation, so the
# cert-manager solver's own router (no middleware, more specific path) can still
# answer the challenge on :80.
#
# Referenced from an Ingress as: dezky-apps-redirect-https@kubernetescrd
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-https
namespace: dezky-apps
spec:
redirectScheme:
scheme: https
permanent: true
@@ -23,6 +23,19 @@ stringData:
STALWART_ADMIN_PASSWORD: "REPLACE_WITH_SAME_AS_HOST" STALWART_ADMIN_PASSWORD: "REPLACE_WITH_SAME_AS_HOST"
# MUST equal the host's STALWART_WEBHOOK_SECRET (audit webhook HMAC). # MUST equal the host's STALWART_WEBHOOK_SECRET (audit webhook HMAC).
STALWART_WEBHOOK_SECRET: "REPLACE_WITH_SAME_AS_HOST" STALWART_WEBHOOK_SECRET: "REPLACE_WITH_SAME_AS_HOST"
# Authentik admin API token (tenant/user provisioning). The Helm bootstrap
# token works: dezky-auth/authentik-secret.AUTHENTIK_BOOTSTRAP_TOKEN.
AUTHENTIK_API_TOKEN: "REPLACE_WITH_authentik-secret.AUTHENTIK_BOOTSTRAP_TOKEN"
# Tamper-evidence signing key for the audit hash chain. Rotating it closes
# the current segment — back it up alongside SCHEDULING_CREDENTIAL_KEY.
AUDIT_SIGNING_KEY: "REPLACE_WITH_openssl_rand_hex_32"
# Hetzner Object Storage IAM pair for audit cold storage. Not provisioned
# yet — keep the placeholders until the bucket exists (ARCHIVE_ENABLED is
# false in platform-api-config.yaml; the client only fails when used).
AUDIT_COLD_ACCESS_KEY: "not-configured"
AUDIT_COLD_SECRET_KEY: "not-configured"
# OCIS service-user password (files tier — not deployed in prod yet).
OCIS_SVC_PASSWORD: "not-configured"
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
@@ -34,7 +47,7 @@ stringData:
# Authentik OIDC client provisioned for the portal. # Authentik OIDC client provisioned for the portal.
NUXT_OIDC_CLIENT_ID: "REPLACE" NUXT_OIDC_CLIENT_ID: "REPLACE"
NUXT_OIDC_CLIENT_SECRET: "REPLACE" NUXT_OIDC_CLIENT_SECRET: "REPLACE"
NUXT_OIDC_REDIRECT_URI: "https://app.dezky.eu/auth/callback" NUXT_OIDC_REDIRECT_URI: "https://app.dezky.eu/auth/oidc/callback"
# Public base URL of Authentik (used for login redirects + full sign-out). # Public base URL of Authentik (used for login redirects + full sign-out).
NUXT_PUBLIC_AUTH_URL: "https://auth.dezky.eu" NUXT_PUBLIC_AUTH_URL: "https://auth.dezky.eu"
# nuxt-oidc-auth session encryption secret (openssl rand -hex 32). # nuxt-oidc-auth session encryption secret (openssl rand -hex 32).
@@ -50,3 +63,20 @@ stringData:
# Cloudflare Turnstile site key for the public booking form (public value, # Cloudflare Turnstile site key for the public booking form (public value,
# env-injected so it can rotate without a rebuild). # env-injected so it can rotate without a rebuild).
NUXT_PUBLIC_TURNSTILE_SITE_KEY: "REPLACE" NUXT_PUBLIC_TURNSTILE_SITE_KEY: "REPLACE"
---
apiVersion: v1
kind: Secret
metadata:
name: operator-secrets
namespace: dezky-apps
type: Opaque
stringData:
# Authentik OIDC client provisioned for the operator (dezky-operator).
# NUXT_OIDC_CLIENT_SECRET MUST equal authentik-secret.OPERATOR_OIDC_CLIENT_SECRET.
NUXT_OIDC_CLIENT_ID: "dezky-operator"
NUXT_OIDC_CLIENT_SECRET: "REPLACE_WITH_authentik-secret.OPERATOR_OIDC_CLIENT_SECRET"
NUXT_OIDC_REDIRECT_URI: "https://operator.dezky.eu/auth/oidc/callback"
# Public base URL of Authentik (used for login redirects + full sign-out).
NUXT_PUBLIC_AUTH_URL: "https://auth.dezky.eu"
# nuxt-oidc-auth session encryption secret (openssl rand -hex 32).
NUXT_OIDC_SESSION_SECRET: "REPLACE_WITH_openssl_rand_hex_32"
@@ -0,0 +1,90 @@
# ServiceAccount the CI deploy job uses to roll out the app tier.
#
# The Gitea Actions runner lives in this cluster (see gitea-runner.yaml), so
# the deploy job in .gitea/workflows/ci.yml talks straight to the API server
# on the in-cluster service IP (https://10.43.0.1) with a kubeconfig built
# from this ServiceAccount's token. Scope: admin within dezky-apps only, plus
# the Traefik Middleware CRD ('admin' doesn't aggregate CRDs) and read/patch
# on the dezky-apps Namespace object itself (kustomization includes it).
#
# Mint the kubeconfig + store it as the KUBECONFIG_B64 Gitea repo secret —
# one-time, documented in ../README.md.
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: dezky-apps
---
# Long-lived token (k8s no longer auto-creates these for ServiceAccounts).
apiVersion: v1
kind: Secret
metadata:
name: ci-deployer-token
namespace: dezky-apps
annotations:
kubernetes.io/service-account.name: ci-deployer
type: kubernetes.io/service-account-token
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deployer-admin
namespace: dezky-apps
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: admin
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: dezky-apps
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ci-deployer-traefik
namespace: dezky-apps
rules:
- apiGroups: ["traefik.io"]
resources: ["middlewares"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deployer-traefik
namespace: dezky-apps
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ci-deployer-traefik
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: dezky-apps
---
# kubectl apply -k includes namespace.yaml; name-scoped so this SA can only
# touch the dezky-apps Namespace (create is intentionally absent — if the
# namespace is ever gone, bootstrap it by hand per the RUNBOOK).
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: ci-deployer-namespace
rules:
- apiGroups: [""]
resources: ["namespaces"]
resourceNames: ["dezky-apps"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: ci-deployer-namespace
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: ci-deployer-namespace
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: dezky-apps
@@ -1,14 +1,19 @@
# Customise the k3s-bundled Traefik: redirect ALL HTTP (:80) → HTTPS (:443) # Customise the k3s-bundled Traefik.
# globally, for every Ingress on the cluster.
# #
# k3s manages Traefik via a HelmChart named 'traefik' in kube-system; a # k3s manages Traefik via a HelmChart named 'traefik' in kube-system; a
# HelmChartConfig of the same name MERGES these values into it (k3s re-runs the # HelmChartConfig of the same name MERGES these values into it (k3s re-runs the
# install). We inject the redirect as Traefik static-config args # install).
# (additionalArguments) — version-independent, unlike the chart's
# ports.web.redirectTo value which didn't render on this chart version.
# #
# HTTP-01 ACME is unaffected: Let's Encrypt follows the 308 to HTTPS, so # HTTP→HTTPS redirect is deliberately NOT done here at the entrypoint level.
# cert-manager challenges still validate. # A global `entrypoints.web.http.redirections` is a catch-all that runs BEFORE
# any router, so it 301s the ACME HTTP-01 challenge (:80 /.well-known/
# acme-challenge/...) to HTTPS before cert-manager's solver can answer — and the
# solver isn't served on the websecure-only app ingresses, so the challenge
# 404s and Let's Encrypt issuance fails. Instead each app Ingress carries a
# `redirectScheme` Middleware (see apps/redirect-middleware.yaml +
# authentik/redirect-middleware.yaml): real traffic still gets 301'd to HTTPS,
# but port 80 stays open so the cert-manager solver (a separate, more-specific
# router with no middleware) can complete the challenge.
apiVersion: helm.cattle.io/v1 apiVersion: helm.cattle.io/v1
kind: HelmChartConfig kind: HelmChartConfig
metadata: metadata:
@@ -16,9 +21,4 @@ metadata:
namespace: kube-system namespace: kube-system
spec: spec:
valuesContent: |- valuesContent: |-
additionalArguments: additionalArguments: []
# to=:443 (NOT 'websecure') — the websecure entrypoint listens on :8443
# internally, which isn't exposed; redirect to the public 443 instead.
- "--entrypoints.web.http.redirections.entrypoint.to=:443"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"