// infra
Infrastructure
arcane@prod:~$ cat infra/**
How this page reaches you — the actual infrastructure, read straight from the repo at build time (no hand-copied snippets, so it can never drift). A single-node k3s box behind a Cloudflare Tunnel, GitOps-deployed: push to main, CI builds the image, ArgoCD syncs the cluster.
CI/CD — build & deploy the web app
.github/workflows/web.yml
GitHub Actions builds the Next.js image, pushes it to GHCR, then pins the tag into kustomize with [skip ci]; ArgoCD on the box watches main and syncs. Push to main = deploy.
name: web image
on:
push:
branches: [main]
paths:
- "apps/web/**"
- "packages/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/web.yml"
workflow_dispatch:
concurrency:
group: web-image
cancel-in-progress: false
env:
IMAGE: ghcr.io/gabrielipcarvalho/gipc-web
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write # to commit the GitOps tag bump
packages: write # to push to GHCR
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push image
id: build
uses: docker/build-push-action@v6
with:
context: .
file: apps/web/Dockerfile
push: true
tags: |
${{ env.IMAGE }}:${{ github.sha }}
${{ env.IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: GitOps — pin manifest to this image (deploy feed)
id: pin
run: |
# rebase-retry: two workflows can race to pin on the same main push; the loser fetches the
# winner's commit, re-applies its own tag, and retries. FETCH_HEAD is fresh on a shallow clone.
git config user.name "gipc-ci"
git config user.email "[email protected]"
for attempt in 1 2 3 4 5; do
git fetch origin main
git reset --hard FETCH_HEAD
sed -i "s|newTag: .*|newTag: \"${GITHUB_SHA}\"|" infra/k8s/web/kustomization.yaml
if git diff --quiet; then echo "already pinned"; exit 0; fi
git commit -am "deploy(web): ${GITHUB_SHA:0:7} → gipc.dev [skip ci]"
if git push; then echo "pinned (attempt $attempt)"; exit 0; fi
echo "push rejected — another workflow pushed; retrying"
[ "$attempt" -lt 5 ] && sleep $((attempt * 3))
done
echo "::error::failed to pin web after 5 attempts"; exit 1
- name: Notify deploy feed
# post whenever the image built (even if the pin retried/failed), but never on a cancelled run.
if: ${{ !cancelled() && github.ref == 'refs/heads/main' && steps.build.outcome == 'success' }}
env:
DEPLOY_HOOK_KEY: ${{ secrets.DEPLOY_HOOK_KEY }}
# released reflects the PIN outcome — a failed pin = manifest not bumped = site NOT on the new image
RELEASED_STATUS: ${{ steps.pin.outcome == 'success' && 'success' || 'failure' }}
run: |
[ -z "$DEPLOY_HOOK_KEY" ] && { echo "no key"; exit 0; }
SUBJECT=$(git log -1 --format=%s "$GITHUB_SHA"); TS=$(date -u +%FT%TZ)
post() { # $1 stage, $2 status — jq builds subject-safe JSON; sign the exact bytes sent
body=$(jq -nc --arg sha "$GITHUB_SHA" --arg subject "$SUBJECT" --arg stage "$1" --arg status "$2" --arg ts "$TS" \
'{sha:$sha,subject:$subject,stage:$stage,status:$status,ts:$ts}')
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$DEPLOY_HOOK_KEY" | awk '{print $2}')
curl -fsS -X POST -H "X-Signature: sha256=$sig" -H 'Content-Type: application/json' --data "$body" https://gipc.dev/api/hooks/deploy || true
}
post build success
post released "$RELEASED_STATUS"
Go core — Deployment, Service & config
infra/k8s/core/core.yaml
The stdlib Go backend: single replica, distroless non-root, read-only rootfs, 12-factor config via ConfigMap. The HMAC secret is referenced by name, never inlined.
apiVersion: v1
kind: ConfigMap
metadata:
name: core-config
namespace: gipc
data:
PORT: "8080"
CORS_ORIGIN: "https://gipc.dev"
RATE_LIMIT_RPS: "10"
RATE_LIMIT_BURST: "20"
PROMETHEUS_URL: "http://prometheus.observability:9090" # used from P3
LOKI_URL: "http://loki.observability:3100" # P6 log surface (cross-ns ClusterIP)
WEB_URL: "http://web:80" # P7 uptime probe target
UPTIME_INTERVAL: "30s" # P7 probe cadence
SHUTDOWN_TIMEOUT: "25s"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: core
namespace: gipc
labels: { app: core }
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
selector:
matchLabels: { app: core }
template:
metadata:
labels: { app: core }
spec:
terminationGracePeriodSeconds: 30 # ≥ SHUTDOWN_TIMEOUT so /api/* drains on a roll
securityContext:
runAsNonRoot: true
runAsUser: 65532 # distroless:nonroot
fsGroup: 65532
seccompProfile: { type: RuntimeDefault }
containers:
- name: core
image: ghcr.io/gabrielipcarvalho/gipc-core:latest # tag pinned by CI via kustomization
ports:
- { containerPort: 8080, name: http }
envFrom:
- configMapRef: { name: core-config }
env:
- name: DEPLOY_HOOK_KEY # HMAC key for /api/hooks/deploy — imperative Secret, never committed
valueFrom:
secretKeyRef: { name: deploy-hook, key: hmac }
readinessProbe:
httpGet: { path: /api/readyz, port: 8080 }
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet: { path: /api/healthz, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 20
resources:
requests: { cpu: 25m, memory: 32Mi }
limits: { cpu: 200m, memory: 128Mi }
securityContext: # static binary writes nothing → full lockdown
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
name: core
namespace: gipc
labels: { app: core }
spec:
type: ClusterIP
selector: { app: core }
ports:
- { port: 8080, targetPort: 8080, name: http }
Kustomize — the image pin CI writes
infra/k8s/core/kustomization.yaml
CI rewrites the built image tag here; ArgoCD reconciles the cluster to match. This file is the deploy handshake.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# One app manages core + caddy. Each manifest sets its own namespace: gipc (no namespace directive here).
# Caddy's image is pinned inline in ../caddy (no images: entry), so CI's tag-pin sed matches only the
# core image line below. Keep comments free of the literal tag-key token so the sed can't match them.
resources:
- core.yaml
- ../caddy
images:
- name: ghcr.io/gabrielipcarvalho/gipc-core
newTag: "691b07dfe1e6e378f705c9326d42e3e886c01714"
Caddy — reverse proxy & metrics
infra/k8s/caddy/caddy.yaml
Fronts the cluster inside k3s: routes /api/* to core (SSE-safe, flush-immediate), everything else to web, and exposes its own Prometheus metrics.
apiVersion: v1
kind: ConfigMap
metadata:
name: caddy-config
namespace: gipc
data:
Caddyfile: |
{
auto_https off
admin off
servers {
trusted_proxies static private_ranges # trust cloudflared → preserve X-Forwarded-Proto/For
metrics # enable per-server Prometheus metrics
}
}
:80 {
handle /caddy-health {
respond 200
}
handle /api/telemetry {
reverse_proxy web:80
}
handle /api/* {
reverse_proxy core:8080 {
flush_interval -1
}
}
handle {
reverse_proxy web:80
}
header -Server
}
:2019 {
metrics /metrics # internal only — NOT in the Service/tunnel
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: caddy
namespace: gipc
labels: { app: caddy }
spec:
replicas: 1
selector:
matchLabels: { app: caddy }
template:
metadata:
labels: { app: caddy }
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "2019"
prometheus.io/path: "/metrics"
spec:
securityContext:
seccompProfile: { type: RuntimeDefault }
containers:
- name: caddy
image: caddy:2.8-alpine # pinned INLINE (not in kustomization images:) so CI's sed can't touch it
ports:
- { containerPort: 80, name: http }
- { containerPort: 2019, name: metrics }
readinessProbe:
httpGet: { path: /caddy-health, port: 80 }
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet: { path: /caddy-health, port: 80 }
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests: { cpu: 25m, memory: 32Mi }
limits: { cpu: 150m, memory: 128Mi }
securityContext:
allowPrivilegeEscalation: false # runs root for :80 bind + /data,/config writes — lighter than core
volumeMounts:
- { name: config, mountPath: /etc/caddy }
volumes:
- name: config
configMap: { name: caddy-config }
---
apiVersion: v1
kind: Service
metadata:
name: caddy
namespace: gipc
labels: { app: caddy }
spec:
type: NodePort
selector: { app: caddy }
ports:
- { port: 80, targetPort: 80, nodePort: 30082, name: http }
Cloudflare Tunnel — the front door
infra/cloudflared/config.yml
cloudflared dials out to Cloudflare's edge with zero inbound ports open on the box; gipc.dev ingress lands on the in-cluster Caddy NodePort. The tunnel id is redacted here.
tunnel: ‹tunnel-id›
credentials-file: /etc/cloudflared/gipc.json
originRequest:
connectTimeout: 30s
ingress:
- hostname: gipc.dev
service: http://localhost:30082
- hostname: www.gipc.dev
service: http://localhost:30082
- service: http_status:404