From 661a7e060c9bd0a44c5b9c7c1c64f615b6e050e4 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 24 Aug 2026 20:38:03 +0200 Subject: [PATCH] Add k3s deployment scaffolding for dist/ on Vultr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the static landing page to the existing k3s cluster at 65.20.110.165, served at www.apexturf.es (apexturf.es redirects to it), using a self-hosted Gitea instance as the container registry. - deploy/Dockerfile + nginx.conf: nginx:alpine image serving dist/ (SPA-safe fallback, gzip, cache headers). Build-tested locally against the real dist/ output. - deploy/k8s/apex-turf/: Namespace, Deployment, Service, and two Ingresses (www.apexturf.es + a Traefik Middleware redirect from the bare domain). - deploy/k8s/cert-manager/: Let's Encrypt ClusterIssuers (prod + staging) for HTTP-01 via the cluster's bundled Traefik. - deploy/k8s/gitea/: Helm values for a lightweight, SQLite-backed Gitea at git.apexturf.es with its container registry enabled. - docs/DEPLOYMENT.md: full phased runbook with exact commands, troubleshooting, and a redeploy cheat sheet. - docs/README.md: point at the new deploy/ tree and runbook. Nothing here touches the remote cluster — these are local manifests and docs for the user to apply themselves. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 5 + deploy/Dockerfile | 11 ++ deploy/k8s/apex-turf/deployment.yaml | 46 +++++ deploy/k8s/apex-turf/ingress.yaml | 26 +++ deploy/k8s/apex-turf/namespace.yaml | 4 + deploy/k8s/apex-turf/redirect-ingress.yaml | 42 +++++ deploy/k8s/apex-turf/service.yaml | 11 ++ deploy/k8s/cert-manager/cluster-issuer.yaml | 39 +++++ deploy/k8s/gitea/values.yaml | 85 +++++++++ deploy/nginx.conf | 30 ++++ docs/DEPLOYMENT.md | 185 ++++++++++++++++++++ docs/README.md | 10 +- 12 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 deploy/Dockerfile create mode 100644 deploy/k8s/apex-turf/deployment.yaml create mode 100644 deploy/k8s/apex-turf/ingress.yaml create mode 100644 deploy/k8s/apex-turf/namespace.yaml create mode 100644 deploy/k8s/apex-turf/redirect-ingress.yaml create mode 100644 deploy/k8s/apex-turf/service.yaml create mode 100644 deploy/k8s/cert-manager/cluster-issuer.yaml create mode 100644 deploy/k8s/gitea/values.yaml create mode 100644 deploy/nginx.conf create mode 100644 docs/DEPLOYMENT.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f02d6a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!dist +!dist/** +!deploy +!deploy/** diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..013ad39 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,11 @@ +# Build context is the project root (see docs/DEPLOYMENT.md), not deploy/, so +# COPY paths below are relative to the repo root: +# docker build -f deploy/Dockerfile -t . +FROM nginx:1.27-alpine + +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY dist/ /usr/share/nginx/html/ + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost/ >/dev/null || exit 1 diff --git a/deploy/k8s/apex-turf/deployment.yaml b/deploy/k8s/apex-turf/deployment.yaml new file mode 100644 index 0000000..7c3caa0 --- /dev/null +++ b/deploy/k8s/apex-turf/deployment.yaml @@ -0,0 +1,46 @@ +# CHANGE ME: replace GITEA_OWNER with your Gitea username/org (see +# docs/DEPLOYMENT.md Phase 5) before applying, e.g.: +# sed -i '' 's/GITEA_OWNER/apexadmin/' deploy/k8s/apex-turf/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: apex-turf-web + namespace: apex-turf + labels: + app: apex-turf-web +spec: + replicas: 2 + selector: + matchLabels: + app: apex-turf-web + template: + metadata: + labels: + app: apex-turf-web + spec: + containers: + - name: web + image: git.apexturf.es/GITEA_OWNER/apex-turf-web:latest + ports: + - containerPort: 80 + resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 200m + memory: 64Mi + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 2 + periodSeconds: 5 + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + imagePullSecrets: + - name: gitea-registry diff --git a/deploy/k8s/apex-turf/ingress.yaml b/deploy/k8s/apex-turf/ingress.yaml new file mode 100644 index 0000000..92156ad --- /dev/null +++ b/deploy/k8s/apex-turf/ingress.yaml @@ -0,0 +1,26 @@ +# Serves the site at https://www.apexturf.es (this is the canonical host — +# see redirect-ingress.yaml for the bare apexturf.es -> www redirect). +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: apex-turf-web + namespace: apex-turf + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: traefik + tls: + - hosts: + - www.apexturf.es + secretName: apex-turf-web-tls + rules: + - host: www.apexturf.es + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: apex-turf-web + port: + number: 80 diff --git a/deploy/k8s/apex-turf/namespace.yaml b/deploy/k8s/apex-turf/namespace.yaml new file mode 100644 index 0000000..1ff9a03 --- /dev/null +++ b/deploy/k8s/apex-turf/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: apex-turf diff --git a/deploy/k8s/apex-turf/redirect-ingress.yaml b/deploy/k8s/apex-turf/redirect-ingress.yaml new file mode 100644 index 0000000..8d58efa --- /dev/null +++ b/deploy/k8s/apex-turf/redirect-ingress.yaml @@ -0,0 +1,42 @@ +# Redirects bare https://apexturf.es/* -> https://www.apexturf.es/* (301). +# Needs cert-manager to issue a cert for the bare domain too (browsers hit +# TLS on apexturf.es *before* any HTTP-level redirect can happen), so this +# still requests its own certificate even though all traffic bounces onward. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: redirect-to-www + namespace: apex-turf +spec: + redirectRegex: + regex: ^https?://apexturf\.es/(.*) + replacement: https://www.apexturf.es/${1} + permanent: true +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: apex-turf-redirect + namespace: apex-turf + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.middlewares: apex-turf-redirect-to-www@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - apexturf.es + secretName: apex-turf-root-tls + rules: + - host: apexturf.es + http: + paths: + # Backend is never actually reached — the middleware above redirects + # first — but Ingress requires a rule to attach the middleware to. + - path: / + pathType: Prefix + backend: + service: + name: apex-turf-web + port: + number: 80 diff --git a/deploy/k8s/apex-turf/service.yaml b/deploy/k8s/apex-turf/service.yaml new file mode 100644 index 0000000..e3c4729 --- /dev/null +++ b/deploy/k8s/apex-turf/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: apex-turf-web + namespace: apex-turf +spec: + selector: + app: apex-turf-web + ports: + - port: 80 + targetPort: 80 diff --git a/deploy/k8s/cert-manager/cluster-issuer.yaml b/deploy/k8s/cert-manager/cluster-issuer.yaml new file mode 100644 index 0000000..eb80153 --- /dev/null +++ b/deploy/k8s/cert-manager/cluster-issuer.yaml @@ -0,0 +1,39 @@ +# Requires cert-manager to already be installed (see docs/DEPLOYMENT.md Phase 3). +# k3s ships Traefik as its ingress controller by default, which is what the +# http01 solver below targets. If you disabled Traefik at k3s install time, +# swap `class: traefik` for whatever ingress controller you're running. +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + # CHANGE ME if you'd rather Let's Encrypt expiry/abuse notices go elsewhere. + email: hello@albertabril.com + privateKeySecretRef: + name: letsencrypt-prod-key + solvers: + - http01: + ingress: + class: traefik +--- +# Optional but recommended while testing: Let's Encrypt's staging environment +# has much higher rate limits and issues untrusted (but structurally identical) +# certs, so you can validate the whole chain without risking the production +# rate limit (5 certs/domain/week). Point an Ingress at this issuer first, +# confirm it works, then switch to letsencrypt-prod. +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-staging +spec: + acme: + server: https://acme-staging-v02.api.letsencrypt.org/directory + email: hello@albertabril.com + privateKeySecretRef: + name: letsencrypt-staging-key + solvers: + - http01: + ingress: + class: traefik diff --git a/deploy/k8s/gitea/values.yaml b/deploy/k8s/gitea/values.yaml new file mode 100644 index 0000000..ce42fa6 --- /dev/null +++ b/deploy/k8s/gitea/values.yaml @@ -0,0 +1,85 @@ +# Helm values for the official Gitea chart (https://gitea.com/gitea/helm-chart). +# Tuned for a single small Vultr node: SQLite instead of a bundled Postgres, +# no Redis/cache dependency, modest resource requests. +# +# Install with (see docs/DEPLOYMENT.md Phase 4): +# helm repo add gitea-charts https://dl.gitea.com/charts/ +# helm upgrade --install gitea gitea-charts/gitea \ +# --namespace gitea --create-namespace \ +# -f deploy/k8s/gitea/values.yaml + +replicaCount: 1 + +# SQLite keeps this to a single pod with no extra database dependency. +# Fine for a personal/small-team instance; migrate to Postgres later if this +# ever needs to scale beyond one node. +postgresql: + enabled: false +postgresql-ha: + enabled: false +redis-cluster: + enabled: false + +persistence: + enabled: true + size: 10Gi + # CHANGE ME if your Vultr node's disk is smaller than ~15-20Gi free, or if + # you know you'll host many/large repos and images. + # storageClassName defaults to k3s's built-in "local-path" provisioner. + +gitea: + admin: + # Change this password after first login; it's only the seed value used + # on first boot. Better yet, override it at install time with + # `--set gitea.admin.password=...` instead of committing a real one here. + existingSecret: "" + username: apexadmin + password: "changeme-before-first-boot" + email: "hello@albertabril.com" + + config: + server: + DOMAIN: git.apexturf.es + ROOT_URL: https://git.apexturf.es/ + HTTP_PORT: 3000 + database: + DB_TYPE: sqlite3 + session: + PROVIDER: memory + cache: + ADAPTER: memory + queue: + TYPE: level + # Package registry (incl. the Docker/OCI container registry) is enabled + # by default on modern Gitea, but set explicitly so this doesn't silently + # depend on the chart's/Gitea's current default. + packages: + ENABLED: true + +service: + http: + type: ClusterIP + port: 3000 + +ingress: + enabled: true + className: traefik + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: git.apexturf.es + paths: + - path: / + pathType: Prefix + tls: + - secretName: gitea-tls + hosts: + - git.apexturf.es + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..21dd657 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip static text assets + gzip on; + gzip_vary on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + + # Long cache for fingerprint-free static assets; short cache for HTML so + # deploys show up without needing a hard-refresh. + location ~* \.(?:css|js|png|jpg|jpeg|svg|ico|woff2?)$ { + expires 7d; + add_header Cache-Control "public, max-age=604800, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } + + # Basic hardening headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + error_page 404 /index.html; +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..3d69bec --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,185 @@ +# Deployment Runbook — k3s on Vultr + +Deploys `dist/` as a static site on the existing k3s cluster at **65.20.110.165**, served at +**https://www.apexturf.es** (with `apexturf.es` redirecting to it), using a self-hosted **Gitea** instance +at **https://git.apexturf.es** as the container registry. + +You run every command below yourself (via `!` in Claude Code or your own terminal) — nothing here is +executed automatically. Each phase assumes the previous one succeeded; verify before moving on. + +**Assumptions/placeholders to double check before you start:** +- You have root SSH access to `65.20.110.165` already (this box was provisioned outside this session). +- k3s was installed with its default bundled **Traefik** ingress controller (verify in Phase 1). +- DNS for `apexturf.es` is managed somewhere you can add A records (any registrar/DNS host works — + instructions below are provider-agnostic). +- Let's Encrypt contact email is set to `hello@albertabril.com` in + `deploy/k8s/cert-manager/cluster-issuer.yaml` — change it there if you'd rather use another address. +- Gitea owner/username is assumed to be `apexadmin` in examples below — swap in whatever you actually pick. + +--- + +## Phase 0 — Local tools + +```bash +# You already have Docker. Confirm kubectl + helm are installed: +kubectl version --client +helm version + +# If either is missing: +brew install kubectl helm +``` + +## Phase 1 — Get kubectl talking to the cluster + +```bash +# Pull the kubeconfig off the node (adjust the ssh user if it's not root): +scp root@65.20.110.165:/etc/rancher/k3s/k3s.yaml ~/.kube/apexturf-k3s.yaml + +# The file points at 127.0.0.1 by default — repoint it at the real IP. +# (macOS/BSD sed needs the empty '' after -i; drop it on Linux.) +sed -i '' 's/127\.0\.0\.1/65.20.110.165/' ~/.kube/apexturf-k3s.yaml +chmod 600 ~/.kube/apexturf-k3s.yaml + +export KUBECONFIG=~/.kube/apexturf-k3s.yaml +kubectl get nodes +kubectl get pods -n kube-system # confirm a traefik pod is running +``` + +> Add `export KUBECONFIG=~/.kube/apexturf-k3s.yaml` to your shell profile (`~/.zshrc`) so it's set in new +> terminals — every command below assumes it's exported. + +**If `kubectl get nodes` times out:** the Vultr firewall group attached to this instance likely doesn't +allow inbound 6443 from your IP. Add a rule for it (and for 80/443, needed later for Let's Encrypt) in the +Vultr dashboard → your instance → Firewall. + +## Phase 2 — DNS + +At whatever DNS host manages `apexturf.es`, add three **A** records, all pointing at `65.20.110.165`: + +| Host | Type | Value | +|---------------------|------|-----------------| +| `apexturf.es` (`@`) | A | 65.20.110.165 | +| `www.apexturf.es` | A | 65.20.110.165 | +| `git.apexturf.es` | A | 65.20.110.165 | + +Verify propagation before moving on (can take minutes to hours depending on TTL/provider): + +```bash +dig +short apexturf.es +dig +short www.apexturf.es +dig +short git.apexturf.es +``` + +## Phase 3 — cert-manager + Let's Encrypt + +```bash +# Check https://cert-manager.io/docs/installation/ for the current latest version — +# this pins a known-good one, but a newer release is likely available: +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml + +kubectl -n cert-manager rollout status deploy/cert-manager +kubectl -n cert-manager rollout status deploy/cert-manager-webhook + +kubectl apply -f deploy/k8s/cert-manager/cluster-issuer.yaml +kubectl get clusterissuer +``` + +Both `letsencrypt-prod` and `letsencrypt-staging` should show `READY=True`. Every Ingress below requests +`letsencrypt-prod` directly; if you want to dry-run against staging first (untrusted cert, but validates +the whole HTTP-01 chain without touching the production rate limit), temporarily change the +`cert-manager.io/cluster-issuer` annotation to `letsencrypt-staging` on an Ingress, confirm it issues, then +switch it back. + +## Phase 4 — Gitea (registry + git host) + +```bash +helm repo add gitea-charts https://dl.gitea.com/charts/ +helm repo update + +helm upgrade --install gitea gitea-charts/gitea \ + --namespace gitea --create-namespace \ + -f deploy/k8s/gitea/values.yaml \ + --set gitea.admin.password='PICK-A-STRONG-PASSWORD-HERE' + +kubectl -n gitea rollout status deploy/gitea +kubectl -n gitea get certificate # wait for READY=True on the git.apexturf.es cert +``` + +Then: +1. Visit **https://git.apexturf.es**, log in as `apexadmin` / the password you set above. +2. **Change that password** (Settings → Account) — the one above only exists in your shell history/values + override now, not committed anywhere. +3. Settings → Applications → **Generate New Token**, scopes `read:package` + `write:package` (this is what + `docker login`/`docker push` will use — don't reuse your account password for that). Save the token + somewhere safe; Gitea only shows it once. + +## Phase 5 — Build & push the site image + +From the project root (not `deploy/` — the Dockerfile expects repo-root as build context): + +```bash +docker login git.apexturf.es -u apexadmin # paste the access token as the password when prompted + +docker build -f deploy/Dockerfile -t git.apexturf.es/apexadmin/apex-turf-web:latest . +docker push git.apexturf.es/apexadmin/apex-turf-web:latest +``` + +Confirm it landed: on Gitea, go to your user (or org) → **Packages** tab, you should see +`apex-turf-web:latest`. + +## Phase 6 — Deploy the site + +```bash +# 1. Namespace first +kubectl apply -f deploy/k8s/apex-turf/namespace.yaml + +# 2. Let the cluster pull from Gitea's registry — same token as Phase 4/5 +kubectl create secret docker-registry gitea-registry \ + --namespace apex-turf \ + --docker-server=git.apexturf.es \ + --docker-username=apexadmin \ + --docker-password='PASTE-THE-ACCESS-TOKEN-HERE' \ + --docker-email=hello@albertabril.com + +# 3. Point the Deployment at your actual Gitea owner (default in the file is GITEA_OWNER) +sed -i '' 's/GITEA_OWNER/apexadmin/' deploy/k8s/apex-turf/deployment.yaml + +# 4. Apply everything else +kubectl apply -f deploy/k8s/apex-turf/deployment.yaml +kubectl apply -f deploy/k8s/apex-turf/service.yaml +kubectl apply -f deploy/k8s/apex-turf/ingress.yaml +kubectl apply -f deploy/k8s/apex-turf/redirect-ingress.yaml +``` + +## Phase 7 — Verify + +```bash +kubectl -n apex-turf get pods,svc,ingress,certificate +kubectl -n apex-turf rollout status deploy/apex-turf-web + +curl -I https://www.apexturf.es # expect 200 +curl -I https://apexturf.es # expect 301 -> https://www.apexturf.es/ +``` + +Open **https://www.apexturf.es** in a browser and confirm the site loads with a valid padlock. + +### Troubleshooting + +- **Certificate stuck `False`/pending**: `kubectl -n apex-turf describe certificate apex-turf-web-tls` and + `kubectl -n apex-turf describe challenge` — almost always DNS not propagated yet, or port 80 blocked by + the Vultr firewall (HTTP-01 needs 80 reachable from the internet, not just 443). +- **`ImagePullBackOff`**: check `kubectl -n apex-turf describe pod ` — usually the `gitea-registry` + secret's token is wrong/expired, or `GITEA_OWNER` in `deployment.yaml` doesn't match the actual + image path you pushed to. +- **Redirect not firing**: `kubectl -n apex-turf get middleware` should list `redirect-to-www`; if Traefik + isn't picking it up, confirm the Ingress annotation matches + `-@kubernetescrd` exactly (`apex-turf-redirect-to-www@kubernetescrd`). + +## Redeploying after changes to `dist/` + +```bash +docker build -f deploy/Dockerfile -t git.apexturf.es/apexadmin/apex-turf-web:latest . +docker push git.apexturf.es/apexadmin/apex-turf-web:latest +kubectl -n apex-turf rollout restart deployment/apex-turf-web +kubectl -n apex-turf rollout status deployment/apex-turf-web +``` diff --git a/docs/README.md b/docs/README.md index e110379..31d10e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,15 @@ dist/ docs/ ├── README.md # this file ├── Changelog.md -└── CONTENT-SPECS.md # all 7 pdfs/ specs archived as markdown (4 used, 2 not-yet-built, 1 duplicate) +├── CONTENT-SPECS.md # all 7 pdfs/ specs archived as markdown (4 used, 2 not-yet-built, 1 duplicate) +└── DEPLOYMENT.md # runbook: ship dist/ to the k3s cluster on Vultr +deploy/ +├── Dockerfile # nginx:alpine serving dist/ (build context = repo root) +├── nginx.conf +└── k8s/ + ├── apex-turf/ # Namespace, Deployment, Service, Ingress (+www redirect) for the site + ├── cert-manager/ # Let's Encrypt ClusterIssuers + └── gitea/ # Helm values for the self-hosted Gitea registry ``` To preview locally: open `dist/index.html` directly in a browser, or serve the folder