feat: deploy to Frostmoln production (#243)
unbound-site / build (push) Successful in 1m50s
unbound-site / deploy-prod-frostmoln (push) Successful in 1m3s

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: #243
This commit was merged in pull request #243.
This commit is contained in:
argoyle committed 2026-10-14 17:53:12 +00:00
1 parent b8b30a4fbe
commit 283852c438
5 files changed
+37 -17

No files matched your search

+18 -4
View File
@@ -18,16 +18,30 @@ jobs:
- name: Build and push - name: Build and push
run: unset GITEA_TOKEN && build && push run: unset GITEA_TOKEN && build && push
deploy-prod: deploy-prod-frostmoln:
needs: build needs: build
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
BUILDTOOLS_CONTENT: ${{ secrets.BUILDTOOLS_CONTENT }} BUILDTOOLS_CONTENT: ${{ secrets.BUILDTOOLS_FROSTMOLN_PRODUCTION }}
GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REPOSITORY: ${{ gitea.repository }}
FM_TENANT: 9ae61342-0f76-476f-8176-e46f6b0b0c32
environment: prod environment: prod
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: buildtool/setup-buildtools-action@v1 - uses: buildtool/setup-buildtools-action@v1
- name: Deploy to production - name: Install fm
run: deploy prod 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
+2 -1
View File
@@ -13,7 +13,8 @@ COPY ./ /site
WORKDIR /site WORKDIR /site
RUN hugo 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 ADD default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /site/public /usr/share/nginx/html COPY --from=build /site/public /usr/share/nginx/html
+1 -1
View File
@@ -1,5 +1,5 @@
server { server {
listen 80; listen 8080;
server_name localhost; server_name localhost;
access_log /dev/stdout combined; access_log /dev/stdout combined;
+12 -4
View File
@@ -22,6 +22,11 @@ spec:
labels: labels:
app.kubernetes.io/name: unbound-site app.kubernetes.io/name: unbound-site
spec: spec:
securityContext:
runAsNonRoot: true
runAsUser: 101
seccompProfile:
type: RuntimeDefault
affinity: affinity:
podAntiAffinity: podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution: preferredDuringSchedulingIgnoredDuringExecution:
@@ -39,14 +44,18 @@ spec:
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /health path: /health
port: 80 port: 8080
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 5 periodSeconds: 5
timeoutSeconds: 5 timeoutSeconds: 5
imagePullPolicy: Always imagePullPolicy: Always
image: ${IMAGE} image: ${IMAGE}
ports: ports:
- containerPort: 80 - containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources: resources:
requests: requests:
cpu: 5m cpu: 5m
@@ -63,9 +72,8 @@ metadata:
labels: labels:
app.kubernetes.io/name: unbound-site app.kubernetes.io/name: unbound-site
spec: spec:
type: NodePort
selector: selector:
app.kubernetes.io/name: unbound-site app.kubernetes.io/name: unbound-site
ports: ports:
- port: 80 - port: 80
targetPort: 80 targetPort: 8080
@@ -1,15 +1,12 @@
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: unbound-site-ingress name: unbound-site
annotations: annotations:
kubernetes.io/ingress.class: "alb" traefik.ingress.kubernetes.io/router.entrypoints: websecure
alb.ingress.kubernetes.io/group.name: default traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
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"
spec: spec:
ingressClassName: traefik
rules: rules:
- host: "unbound.se" - host: "unbound.se"
http: http: