k8s-native stack: Traefik edge + cert-manager LE DNS-01 + per-site static deployments

This commit is contained in:
zai-agent 2026-09-15 23:06:02 +00:00
parent 0e68abc772
commit f33385dacc
9 changed files with 397 additions and 126 deletions

View file

@ -1,11 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build the static site and ensure the edge nginx serves it. # Build sites and sync docroots. Routing/TLS are handled by Traefik + cert-manager
# Content changes need no pod restart (hostPath is live); config changes trigger a reload. # in the cluster, so content deploys need no pod reloads or restarts.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
BASE=/home/opc/zai-home-base BASE=/home/opc/zai-home-base
K="sudo /usr/local/bin/k3s kubectl"
echo "==> building site" echo "==> building site"
python3 build.py python3 build.py
@ -13,25 +11,18 @@ python3 build.py
mkdir -p "$BASE/sites" mkdir -p "$BASE/sites"
rm -rf "$BASE/sites/bestdadjokes.new" rm -rf "$BASE/sites/bestdadjokes.new"
cp -r site "$BASE/sites/bestdadjokes.new" cp -r site "$BASE/sites/bestdadjokes.new"
# ensure SELinux-friendly context if SELinux is enforcing
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" = "Enforcing" ]; then if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" = "Enforcing" ]; then
chcon -Rt container_file_t "$BASE/sites/bestdadjokes.new" 2>/dev/null || true chcon -Rt container_file_t "$BASE/sites/bestdadjokes.new" 2>/dev/null || true
chcon -Rt container_file_t "$BASE/bdj/nginx/conf.d" 2>/dev/null || true
fi fi
rm -rf "$BASE/sites/bestdadjokes.old" rm -rf "$BASE/sites/bestdadjokes.old"
[ -d "$BASE/sites/bestdadjokes" ] && mv "$BASE/sites/bestdadjokes" "$BASE/sites/bestdadjokes.old" [ -d "$BASE/sites/bestdadjokes" ] && mv "$BASE/sites/bestdadjokes" "$BASE/sites/bestdadjokes.old"
mv "$BASE/sites/bestdadjokes.new" "$BASE/sites/bestdadjokes" mv "$BASE/sites/bestdadjokes.new" "$BASE/sites/bestdadjokes"
rm -rf "$BASE/sites/bestdadjokes.old" rm -rf "$BASE/sites/bestdadjokes.old"
echo "==> applying edge manifest" echo "==> verifying through Traefik (origin)"
$K apply -f k8s/nginx-edge.yaml
POD=$($K get pods -l app=nginx-edge -o jsonpath='{.items[0].metadata.name}')
echo "==> reloading nginx config in pod $POD"
$K exec "$POD" -- nginx -s reload 2>/dev/null || true
echo "==> verifying"
sleep 1 sleep 1
code=$($K exec "$POD" -- wget -q -O- --header="Host: bestdadjokes.lol" http://127.0.0.1/ | head -c 60) for host in bestdadjokes.lol www.bestdadjokes.lol myadhd.dev; do
echo "first bytes of homepage: $code" code=$(curl -sk -o /dev/null -w '%{http_code}' --resolve "$host:443:127.0.0.1" "https://$host/" --max-time 10)
echo " https://$host -> $code (origin)"
done
echo "==> deploy complete" echo "==> deploy complete"

View file

@ -0,0 +1,23 @@
# Let's Encrypt via cert-manager, DNS-01 through the Cloudflare API token.
# The token lives in Secret cert-manager/cloudflare-api-token (created out-of-band
# via `kubectl create secret ... --from-file=api-token=...`; never committed).
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: hello@bestdadjokes.lol
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- bestdadjokes.lol
- myadhd.dev

View file

@ -1,66 +0,0 @@
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: nginx-edge
namespace: default
labels:
app: nginx-edge
spec:
selector:
matchLabels:
app: nginx-edge
template:
metadata:
labels:
app: nginx-edge
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
kubernetes.io/hostname: djg-oracle-sl
tolerations:
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: nginx
image: nginx:alpine
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
hostPort: 80
protocol: TCP
volumeMounts:
- name: conf
mountPath: /etc/nginx/conf.d
readOnly: true
- name: sites
mountPath: /srv/www
readOnly: true
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
memory: 128Mi
livenessProbe:
httpGet:
path: /
port: 80
httpHeaders:
- name: Host
value: bestdadjokes.lol
initialDelaySeconds: 5
periodSeconds: 30
volumes:
- name: conf
hostPath:
path: /home/opc/zai-home-base/bdj/nginx/conf.d
type: Directory
- name: sites
hostPath:
path: /home/opc/zai-home-base/sites
type: DirectoryOrCreate

262
bdj/k8s/static-sites.yaml Normal file
View file

@ -0,0 +1,262 @@
# Static site fleet: one nginx Deployment + Service + Ingress + Certificate per site.
# Docroots come from hostPath /home/opc/zai-home-base/sites/<site> (built by deploy scripts).
# Edge routing/TLS: Traefik (80/443) + cert-manager Let's Encrypt secrets.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: static-nginx-conf
data:
nginx.conf: |
worker_processes 1;
error_log /dev/stderr warn;
pid /run/nginx.pid;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
gzip on;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
gzip_min_length 1024;
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
error_page 404 /404.html;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location ~* \.(css|js|svg|png|jpg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ $uri/index.html =404;
}
}
}
---
# ---------------- bestdadjokes.lol ----------------
apiVersion: apps/v1
kind: Deployment
metadata:
name: static-bdj
labels:
site: bestdadjokes.lol
spec:
replicas: 1
selector:
matchLabels:
site: bestdadjokes.lol
template:
metadata:
labels:
site: bestdadjokes.lol
spec:
nodeSelector:
kubernetes.io/hostname: djg-oracle-sl
tolerations:
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: nginx
image: nginx:alpine
volumeMounts:
- name: conf
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: true
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
memory: 128Mi
readinessProbe:
httpGet: {path: /, port: 80}
initialDelaySeconds: 2
periodSeconds: 20
volumes:
- name: conf
configMap:
name: static-nginx-conf
- name: html
hostPath:
path: /home/opc/zai-home-base/sites/bestdadjokes
type: Directory
---
apiVersion: v1
kind: Service
metadata:
name: static-bdj
spec:
selector:
site: bestdadjokes.lol
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bestdadjokes
spec:
rules:
- host: bestdadjokes.lol
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: static-bdj
port:
number: 80
- host: www.bestdadjokes.lol
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: static-bdj
port:
number: 80
tls:
- hosts:
- bestdadjokes.lol
- www.bestdadjokes.lol
secretName: bestdadjokes-tls
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: bestdadjokes-lol
spec:
secretName: bestdadjokes-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- bestdadjokes.lol
- www.bestdadjokes.lol
---
# ---------------- myadhd.dev (placeholder) ----------------
apiVersion: apps/v1
kind: Deployment
metadata:
name: static-adhd
labels:
site: myadhd.dev
spec:
replicas: 1
selector:
matchLabels:
site: myadhd.dev
template:
metadata:
labels:
site: myadhd.dev
spec:
nodeSelector:
kubernetes.io/hostname: djg-oracle-sl
tolerations:
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: nginx
image: nginx:alpine
volumeMounts:
- name: conf
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: true
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
memory: 128Mi
readinessProbe:
httpGet: {path: /, port: 80}
initialDelaySeconds: 2
periodSeconds: 20
volumes:
- name: conf
configMap:
name: static-nginx-conf
- name: html
hostPath:
path: /home/opc/zai-home-base/sites/myadhd.dev
type: Directory
---
apiVersion: v1
kind: Service
metadata:
name: static-adhd
spec:
selector:
site: myadhd.dev
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myadhd
spec:
rules:
- host: myadhd.dev
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: static-adhd
port:
number: 80
- host: www.myadhd.dev
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: static-adhd
port:
number: 80
tls:
- hosts:
- myadhd.dev
- www.myadhd.dev
secretName: myadhd-tls
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: myadhd-dev
spec:
secretName: myadhd-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- myadhd.dev
- www.myadhd.dev

101
bdj/k8s/traefik.yaml Normal file
View file

@ -0,0 +1,101 @@
# Traefik v3 as the cluster edge: hostNetwork 80/443, global http->https redirect.
# TLS certificates are provisioned by cert-manager into Ingress secrets.
apiVersion: v1
kind: ServiceAccount
metadata:
name: traefik
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: traefik
rules:
- apiGroups: [""]
resources: ["services", "endpoints", "secrets", "namespaces"]
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses", "ingressclasses"]
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses/status"]
verbs: ["update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: traefik
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: traefik
subjects:
- kind: ServiceAccount
name: traefik
namespace: kube-system
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: traefik
namespace: kube-system
labels:
app: traefik
spec:
selector:
matchLabels:
app: traefik
template:
metadata:
labels:
app: traefik
spec:
serviceAccountName: traefik
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
kubernetes.io/hostname: djg-oracle-sl
tolerations:
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: traefik
image: traefik:v3.3
args:
- --providers.kubernetesingress
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.address=:443
- --ping=true
- --ping.entrypoint=health
- --entrypoints.health.address=:8082
ports:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
readinessProbe:
httpGet:
path: /ping
port: 8082
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /ping
port: 8082
initialDelaySeconds: 10
periodSeconds: 30

2
bdj/nginx/README.md Normal file
View file

@ -0,0 +1,2 @@
# Placeholder: the hand-rolled nginx edge was removed in favor of Traefik + cert-manager.
# Vhosts now live as Ingress resources in bdj/k8s/static-sites.yaml.

View file

@ -0,0 +1,2 @@
# Placeholder: self-signed origin certs were removed in favor of cert-manager
# issuing Let's Encrypt certificates into k8s Secrets (see bdj/k8s/letsencrypt-issuer.yaml).

View file

@ -1,37 +0,0 @@
# bestdadjokes.lol
server {
listen 80;
listen [::]:80;
server_name bestdadjokes.lol;
root /srv/www/bestdadjokes;
index index.html;
error_page 404 /404.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
gzip_min_length 1024;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location ~* \.(css|js|svg|png|jpg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public";
}
location = /favicon.ico { return 302 /static/favicon.svg; }
location / {
try_files $uri $uri/ $uri/index.html =404;
}
}
# www redirect
server {
listen 80;
listen [::]:80;
server_name www.bestdadjokes.lol;
return 301 http://bestdadjokes.lol$request_uri;
}

View file

@ -1,7 +0,0 @@
# catch-all: drop requests for unknown hosts
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}