From 283852c438f4d8730acd7de442c9c5a2855b1688 Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Wed, 14 Oct 2026 17:53:12 +0000 Subject: [PATCH] feat: deploy to Frostmoln production (#243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the production deploy from AWS to the Frostmoln production cluster (namespace `sites`). - `deploy-prod` (AWS) has failed since CI left AWS: the runners have no `k8s.unbound.se` context, so the site on AWS still runs an older image. It's replaced by `deploy-prod-frostmoln`, the same pattern as Shiny's `deploy-frostmoln-staging`: `FROSTMOLN_PRODUCTION_DEPLOY_KEY` + `FROSTMOLN_PRODUCTION_CLUSTER_ID` (org), `BUILDTOOLS_FROSTMOLN_PRODUCTION` (org, target `prod-frostmoln`), `deploy prod-frostmoln -n sites`. - Frostmoln enforces restricted Pod Security: the served image is `nginxinc/nginx-unprivileged` (uid 101, port 8080), with a matching securityContext; the Service is ClusterIP. - `ingress-prod.yaml` (ALB) → `ingress-prod-frostmoln.yaml` (Traefik, Let's Encrypt). Checked locally: the image serves `/health` and `/` (200) as uid 101 with all capabilities dropped. **Merge after** infra's `feat/frostmoln-production-sites` is applied (namespace + org secret). The hostnames keep pointing at AWS until the Route 53 switch, a separate step; the certificate is issued after that. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Ma8eHhW59hLAJuLzBnLvUu Reviewed-on: https://git.unbound.se/unboundsoftware/unbound-site/pulls/243 --- .gitea/workflows/ci.yaml | 22 +++++++++++++++---- Dockerfile | 3 ++- default.conf | 2 +- k8s/deploy.yaml | 16 ++++++++++---- ...-prod.yaml => ingress-prod-frostmoln.yaml} | 11 ++++------ 5 files changed, 37 insertions(+), 17 deletions(-) rename k8s/{ingress-prod.yaml => ingress-prod-frostmoln.yaml} (61%) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 7a70b90..a118d26 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -18,16 +18,30 @@ jobs: - name: Build and push run: unset GITEA_TOKEN && build && push - deploy-prod: + deploy-prod-frostmoln: needs: build if: gitea.ref == 'refs/heads/main' runs-on: ubuntu-latest env: - BUILDTOOLS_CONTENT: ${{ secrets.BUILDTOOLS_CONTENT }} + BUILDTOOLS_CONTENT: ${{ secrets.BUILDTOOLS_FROSTMOLN_PRODUCTION }} GITEA_REPOSITORY: ${{ gitea.repository }} + FM_TENANT: 9ae61342-0f76-476f-8176-e46f6b0b0c32 environment: prod steps: - uses: actions/checkout@v7 - uses: buildtool/setup-buildtools-action@v1 - - name: Deploy to production - run: deploy prod + - name: Install fm + run: | + sudo install -d -m 0755 /etc/apt/keyrings + sudo curl -fsSL -o /etc/apt/keyrings/frostmoln.gpg https://get.frostmoln.cloud/deb/frostmoln.gpg + sudo curl -fsSL -o /etc/apt/sources.list.d/frostmoln.sources https://get.frostmoln.cloud/deb/frostmoln.sources + sudo apt-get update && sudo apt-get install -y fm + - name: Deploy to Frostmoln production + env: + FROSTMOLN_API_KEY: ${{ secrets.FROSTMOLN_PRODUCTION_DEPLOY_KEY }} + # The cluster API is private: the kubeconfig's exec plugin opens an fm + # tunnel on demand. fm reads the key from its config file, not the env. + run: | + fm auth api-key set "$FROSTMOLN_API_KEY" > /dev/null + fm kubernetes cluster kubeconfig ${{ vars.FROSTMOLN_PRODUCTION_CLUSTER_ID }} --exec --file "$RUNNER_TEMP/kubeconfig" + KUBECONFIG_CONTENT="$(cat "$RUNNER_TEMP/kubeconfig")" deploy prod-frostmoln -n sites diff --git a/Dockerfile b/Dockerfile index c5b0801..621247d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,8 @@ COPY ./ /site WORKDIR /site RUN hugo -FROM amd64/nginx:1.31.6-alpine@sha256:5f4b1cbc2fac6baad5fd73efa91aef5f40ee315cacc5af719402ae21bb573776 +# Unprivileged (uid 101, port 8080): Frostmoln enforces restricted Pod Security. +FROM nginxinc/nginx-unprivileged:1.31.6-alpine@sha256:884e0b7f42f303641b0f4e78755aea647521dca0b89ca3be7992ad78b5c07434 ADD default.conf /etc/nginx/conf.d/default.conf COPY --from=build /site/public /usr/share/nginx/html diff --git a/default.conf b/default.conf index abe2b7a..0e662e7 100644 --- a/default.conf +++ b/default.conf @@ -1,5 +1,5 @@ server { - listen 80; + listen 8080; server_name localhost; access_log /dev/stdout combined; diff --git a/k8s/deploy.yaml b/k8s/deploy.yaml index 037538e..eea8009 100644 --- a/k8s/deploy.yaml +++ b/k8s/deploy.yaml @@ -22,6 +22,11 @@ spec: labels: app.kubernetes.io/name: unbound-site spec: + securityContext: + runAsNonRoot: true + runAsUser: 101 + seccompProfile: + type: RuntimeDefault affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: @@ -39,14 +44,18 @@ spec: readinessProbe: httpGet: path: /health - port: 80 + port: 8080 initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 5 imagePullPolicy: Always image: ${IMAGE} ports: - - containerPort: 80 + - containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] resources: requests: cpu: 5m @@ -63,9 +72,8 @@ metadata: labels: app.kubernetes.io/name: unbound-site spec: - type: NodePort selector: app.kubernetes.io/name: unbound-site ports: - port: 80 - targetPort: 80 + targetPort: 8080 diff --git a/k8s/ingress-prod.yaml b/k8s/ingress-prod-frostmoln.yaml similarity index 61% rename from k8s/ingress-prod.yaml rename to k8s/ingress-prod-frostmoln.yaml index d371654..a86d129 100644 --- a/k8s/ingress-prod.yaml +++ b/k8s/ingress-prod-frostmoln.yaml @@ -1,15 +1,12 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: unbound-site-ingress + name: unbound-site annotations: - kubernetes.io/ingress.class: "alb" - alb.ingress.kubernetes.io/group.name: default - alb.ingress.kubernetes.io/scheme: internet-facing - alb.ingress.kubernetes.io/target-type: instance - alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80},{"HTTPS": 443}]' - alb.ingress.kubernetes.io/ssl-redirect: "443" + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt spec: + ingressClassName: traefik rules: - host: "unbound.se" http: