Files
website/docs/superpowers/plans/2026-04-26-k8s-deployment.md
Andrew 4a8a6f7564 docs(plan): k8s deployment implementation plan
19 bite-sized tasks covering README/docs cleanup, multi-stage prod
Dockerfile, Helm chart with all templates, values-local + values-us-prod,
Gitea Actions release workflow, and a local k3d e2e smoke test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:31:39 -04:00

70 KiB
Raw Blame History

EZSCALE Website K8s Deployment Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship a production-ready multi-stage Docker image and a Helm chart (ezscale-website) that deploys the EZSCALE Laravel app to the existing K3s cluster, plus reconcile docs against reality.

Architecture: Three Dockerfile targets (app, horizon, scheduler) sharing a runtime base. Helm chart with three Deployments, optional in-cluster Valkey + MariaDB (toggleable per env), operator-managed Database/User/Grant CRDs against the existing mariadb instance in prod, Traefik IngressRoute + cert-manager TLS, Helm-hook migration Job. Two environments: local (k3d/minikube), us-prod (cluster ezs-us-east-prod-01, namespace ezscale).

Tech Stack: PHP 8.3-FPM, nginx 1.30-alpine, Composer 2, Node 24, Helm 3, mariadb-operator (k8s.mariadb.com/v1alpha1), Valkey 9, Traefik 3 IngressRoute, cert-manager, Gitea Container Registry, Gitea Actions.

Spec: docs/superpowers/specs/2026-04-26-k8s-deployment-design.md


Task 1: Reconcile README.md against reality

Files:

  • Modify: README.md

Context: README claims 59 migrations / 29 models / 85 pages / 43 tests. Reality is 94 / 53 / 165 / 51. Lists multi-currency, KB/FAQ, bandwidth as "Not Yet Implemented" — they exist (currencies, knowledge_base_*, bandwidth_usage migrations). Repo URL says GitHub EZSCALE/accounting; real remote is Gitea EZSCALE/website. "Last Updated: March 16, 2026" is stale.

  • Step 1: Pull live counts
cd website
ls database/migrations | wc -l
find app/Models -maxdepth 1 -name '*.php' | wc -l
find resources/ts/Pages -name '*.vue' | wc -l
find tests -name '*Test.php' -o -name '*.spec.php' | wc -l
git -C .. log -1 --format=%cs    # last-commit date
git -C .. remote get-url origin   # real remote

Record outputs. Use them in step 2.

  • Step 2: Edit README.md

In the "Repository" section, replace the GitHub URL with git@git.ezscale.cloud:EZSCALE/website.git. Note Gitea, not GitHub. Issues at https://git.ezscale.cloud/EZSCALE/website/issues.

In "Codebase at a Glance" table, update counts to live values (migrations, models, pages, tests). Recompute test count via find tests -name '*Test.php' | wc -l. Drop the "Assertions" row — it's stale and not worth keeping in sync.

In "Current Status → Implemented Features", add bullets for: multi-currency support, knowledge base + categories, blog (verify by ls website/database/migrations | grep -i blog).

In "Current Status → Not Yet Implemented", remove multi-currency, KB/FAQ, blog (whichever are now done). Keep CI/CD pipeline, staging environment, Cloudflare Zero Trust if still pending — verify each by spot-check.

In the bottom footer, change "Last Updated: March 16, 2026" to today's date and update the line summary if phase status has shifted.

  • Step 3: Verify README markdown renders
cd /home/andrew/local_projects/website
grep -nE 'github\.com|59 |29 |85 |43 ' README.md

Expected: no matches (all stale references gone).

  • Step 4: Commit
git add README.md
git commit -m "docs(readme): reconcile against current reality

Update codebase counts to live values, fix Gitea repo URL (was GitHub),
move multi-currency/KB/blog from 'not yet implemented' to 'implemented',
refresh footer date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 2: Audit and clean outdated top-level files

Files:

  • Inspect: IDEAS.md, TASKS.md, PROJECT_DEVELOPMENT.md, FEATURES.md, ADVANCED_FEATURES.md, KASM_AND_MULTITENANCY.md, GETTING_STARTED.md, ipv4-outreach-tickets.txt
  • Likely delete: ipv4-outreach-tickets.txt
  • Likely modify: GETTING_STARTED.md (verify still accurate), TASKS.md (mark phase status truthfully)

Context: Goal is "remove what's stale, keep what's load-bearing". Don't delete spec docs that future planning still references. The ipv4-outreach-tickets.txt is loose vendor outreach notes that has no business in the repo.

  • Step 1: Inspect each file's current relevance
cd /home/andrew/local_projects/website
for f in IDEAS.md TASKS.md PROJECT_DEVELOPMENT.md FEATURES.md ADVANCED_FEATURES.md KASM_AND_MULTITENANCY.md GETTING_STARTED.md; do
  echo "=== $f ==="
  head -5 "$f"
  echo "(last commit:)"
  git log -1 --format='%cs %s' -- "$f"
done
file ipv4-outreach-tickets.txt
head -10 ipv4-outreach-tickets.txt

Decision rules:

  • ipv4-outreach-tickets.txtdelete, it's operational outreach notes, not project docs.

  • IDEAS.md → keep (long-term ideation, referenced by CLAUDE.md indirectly).

  • TASKS.md → keep but verify accuracy of phase statuses.

  • PROJECT_DEVELOPMENT.md → keep (architecture decisions doc, referenced by CLAUDE.md).

  • FEATURES.md, ADVANCED_FEATURES.md → keep (feature specs).

  • KASM_AND_MULTITENANCY.md → keep (referenced by CLAUDE.md).

  • GETTING_STARTED.md → keep, but verify against current composer run dev flow — fix anything wrong.

  • Step 2: Remove ipv4-outreach-tickets.txt

git rm ipv4-outreach-tickets.txt
  • Step 3: Spot-check GETTING_STARTED.md for staleness
grep -nE 'php artisan|npm|composer|node|mysql' GETTING_STARTED.md | head -20

Compare against actual website/composer.json scripts and website/package.json. If the doc references commands that don't exist, edit it. Common breakage: references to npm run dev when composer run dev is now the canonical one-shot.

  • Step 4: Spot-check TASKS.md Phase 10 status

TASKS.md Phase 10 should reflect that migrations / Cloudflare Zero Trust / staging / CI are still pending, since README still lists them. If TASKS.md already shows them as in-progress with sub-bullets, leave it. If it claims them complete but README says pending, reconcile by updating one to match the other (TASKS.md is generally the source of truth for phase status — update README to match TASKS.md, not the reverse).

  • Step 5: Commit
git add -A
git commit -m "docs: remove ipv4-outreach-tickets.txt; refresh GETTING_STARTED

Outreach notes don't belong in the repo. GETTING_STARTED reconciled
against current composer/npm scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 3: Production multi-stage Dockerfile

Files:

  • Create: Dockerfile (at repo root, NOT inside website/)
  • Create: .dockerignore (at repo root)

Context: The repo has website/ as the Laravel app, plus chart/docs/specs at the root. The build context for the production image is ./website, so the Dockerfile is also under ./website is fine — but the Helm chart and CI all live at the repo root, and we want one image build per repo. Place the Dockerfile at the repo root and use ./website as the build copy source.

  • Step 1: Create .dockerignore
# .dockerignore
.git
.gitignore
.github
.gitea
.idea
.vscode
.claude
.superpowers
.playwright-mcp
docker
docker-compose*.yml
.env*
!website/.env.example
docs
helm
node_modules
website/node_modules
website/vendor
website/storage/logs/*
website/storage/framework/cache/data/*
website/storage/framework/sessions/*
website/storage/framework/views/*
website/.phpunit.cache
website/tests
*.md
Makefile
scripts/whmcs-migrate
ipv4-outreach-tickets.txt
  • Step 2: Create Dockerfile (repo root)
# syntax=docker/dockerfile:1.7

# ==============================================================================
# composer-deps — install PHP deps (no scripts, no dev)
# ==============================================================================
FROM composer:2 AS composer-deps
WORKDIR /app
COPY website/composer.json website/composer.lock ./
RUN composer install \
        --no-dev \
        --no-scripts \
        --no-autoloader \
        --prefer-dist \
        --no-interaction \
        --no-progress
COPY website/ ./
RUN composer dump-autoload --optimize --classmap-authoritative

# ==============================================================================
# node-build — compile assets (Vite)
# ==============================================================================
FROM node:24-alpine AS node-build
WORKDIR /app
COPY website/package.json website/package-lock.json* ./
RUN npm ci --no-audit --no-fund
COPY website/ ./
# vendor/ is needed because Vite reads Laravel's helpers via @vite()
COPY --from=composer-deps /app/vendor ./vendor
RUN npm run build

# ==============================================================================
# runtime-base — common PHP 8.3-FPM image with extensions
# ==============================================================================
FROM php:8.3-fpm-bookworm AS runtime-base

ENV COMPOSER_ALLOW_SUPERUSER=1 \
    COMPOSER_NO_INTERACTION=1

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        git unzip curl ca-certificates \
        libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libwebp-dev \
        libicu-dev libonig-dev libxml2-dev libcurl4-openssl-dev libssl-dev \
        pkg-config \
    && rm -rf /var/lib/apt/lists/*

RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
    && docker-php-ext-install -j"$(nproc)" \
        pdo_mysql intl bcmath gd zip pcntl posix exif sockets opcache

RUN pecl install redis \
    && docker-php-ext-enable redis

# Production PHP / opcache config
RUN { \
        echo 'memory_limit=512M'; \
        echo 'upload_max_filesize=50M'; \
        echo 'post_max_size=50M'; \
        echo 'max_execution_time=120'; \
        echo 'expose_php=Off'; \
    } > /usr/local/etc/php/conf.d/zz-app.ini

RUN { \
        echo 'opcache.enable=1'; \
        echo 'opcache.enable_cli=0'; \
        echo 'opcache.validate_timestamps=0'; \
        echo 'opcache.memory_consumption=256'; \
        echo 'opcache.interned_strings_buffer=16'; \
        echo 'opcache.max_accelerated_files=20000'; \
        echo 'opcache.preload_user=www-data'; \
    } > /usr/local/etc/php/conf.d/zz-opcache.ini

# php-fpm pool — listen on 0.0.0.0:9000 (sidecar nginx connects to localhost)
RUN { \
        echo '[www]'; \
        echo 'user = www-data'; \
        echo 'group = www-data'; \
        echo 'listen = 0.0.0.0:9000'; \
        echo 'pm = dynamic'; \
        echo 'pm.max_children = 20'; \
        echo 'pm.start_servers = 4'; \
        echo 'pm.min_spare_servers = 2'; \
        echo 'pm.max_spare_servers = 6'; \
        echo 'pm.max_requests = 500'; \
        echo 'clear_env = no'; \
    } > /usr/local/etc/php-fpm.d/zz-app.conf

WORKDIR /var/www/html

# Bring in source + autoloaded vendor + built assets
COPY --chown=www-data:www-data --from=composer-deps /app /var/www/html
COPY --chown=www-data:www-data --from=node-build /app/public/build /var/www/html/public/build

# Cache config & routes at build time. Skip if no APP_KEY available.
# These are best-effort — caches will rebuild at runtime if needed.
RUN php artisan config:clear || true \
    && php artisan route:clear || true \
    && php artisan view:clear || true

# Entrypoint
COPY docker/prod-entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

USER www-data

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

# ==============================================================================
# app — php-fpm (paired with nginx sidecar in k8s)
# ==============================================================================
FROM runtime-base AS app
EXPOSE 9000
CMD ["php-fpm"]

# ==============================================================================
# horizon — queue worker
# ==============================================================================
FROM runtime-base AS horizon
STOPSIGNAL SIGTERM
CMD ["php", "artisan", "horizon"]

# ==============================================================================
# scheduler — schedule:work loop
# ==============================================================================
FROM runtime-base AS scheduler
CMD ["php", "artisan", "schedule:work", "--no-interaction"]
  • Step 3: Create production entrypoint
mkdir -p docker

Create docker/prod-entrypoint.sh:

#!/bin/sh
set -e

cd /var/www/html

# Install Passport keys from Secret-mounted location, if present.
# Chart mounts the keys at /var/www/html/secrets/oauth-{public,private}.key.
if [ -f /var/www/html/secrets/oauth-private.key ] \
   && [ -f /var/www/html/secrets/oauth-public.key ]; then
    cp /var/www/html/secrets/oauth-private.key storage/oauth-private.key
    cp /var/www/html/secrets/oauth-public.key storage/oauth-public.key
    chmod 600 storage/oauth-private.key storage/oauth-public.key
fi

# Refresh Laravel caches against current env. Safe to run on every boot —
# config/route/view caching is per-pod and idempotent.
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache || true

exec "$@"
  • Step 4: Build all three targets locally
cd /home/andrew/local_projects/website

docker build --target app       -t ezscale-website:test-app       .
docker build --target horizon   -t ezscale-website:test-horizon   .
docker build --target scheduler -t ezscale-website:test-scheduler .

Expected: each build succeeds, no errors. The first build is slow (~5min); subsequent builds reuse layers.

  • Step 5: Smoke-test images
docker run --rm ezscale-website:test-app       php -v
docker run --rm ezscale-website:test-horizon   php artisan --version
docker run --rm ezscale-website:test-scheduler php -m | grep -E 'opcache|pdo_mysql|redis'

Expected: PHP 8.3.x version, Laravel 12.x version, all three modules listed.

  • Step 6: Verify built assets are present
docker run --rm ezscale-website:test-app ls -la public/build/
docker run --rm ezscale-website:test-app ls -la public/build/assets/ | head

Expected: a manifest.json and an assets/ directory with hashed JS/CSS files.

  • Step 7: Commit
git add Dockerfile .dockerignore docker/prod-entrypoint.sh
git commit -m "feat(docker): production multi-stage Dockerfile

Three named targets (app, horizon, scheduler) sharing a runtime-base
with PHP 8.3-FPM, opcache, redis, and pinned php-fpm pool config.
Composer + Node build stages are separate so vendor/ and public/build/
are baked into the runtime image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: Helm chart skeleton

Files:

  • Create: helm/ezscale-website/Chart.yaml

  • Create: helm/ezscale-website/values.yaml

  • Create: helm/ezscale-website/templates/_helpers.tpl

  • Create: helm/ezscale-website/.helmignore

  • Step 1: Create Chart.yaml

apiVersion: v2
name: ezscale-website
description: EZSCALE billing platform — Laravel app (web + horizon + scheduler)
type: application
version: 0.1.0
appVersion: "0.1.0"
maintainers:
  - name: EZSCALE
    email: dev@ezscale.cloud
  • Step 2: Create values.yaml (defaults — dev-friendly, prod overrides via separate file)
# Default values: lean toward "self-contained dev cluster" so `helm install`
# with no flags produces a working stack on a local k3d. Production values
# live in values-us-prod.yaml and disable the in-cluster MariaDB/Valkey when
# pointing at the existing ezscale-namespace MariaDB.

replicaCount: 1

image:
  registry: git.ezscale.cloud
  repository: ezscale/website
  # The chart appends `-{role}-{tag}` to derive each role's image.
  # Override `tag` per-release via --set image.tag=v0.1.0
  tag: latest
  pullPolicy: IfNotPresent

imagePullSecrets:
  - name: gitea-registry

nameOverride: ""
fullnameOverride: ""

# --- App (php-fpm + nginx sidecar) ---
app:
  replicaCount: 1
  autoscaling:
    enabled: false
    minReplicas: 1
    maxReplicas: 8
    targetCPU: 70
  resources:
    requests:
      cpu: 100m
      memory: 256Mi
    limits:
      cpu: 1000m
      memory: 1Gi

# --- Horizon ---
horizon:
  replicaCount: 1
  resources:
    requests:
      cpu: 100m
      memory: 256Mi

# --- Scheduler ---
scheduler:
  replicaCount: 1
  resources:
    requests:
      cpu: 50m
      memory: 128Mi
    limits:
      cpu: 200m
      memory: 256Mi

# --- In-cluster MariaDB (mariadb-operator CRD) ---
mariadb:
  enabled: true
  # When enabled, deploys a MariaDB CR named `{release}-mariadb` in this
  # release's namespace. When disabled, the chart still creates Database/User/
  # Grant CRDs but they reference an externally-managed MariaDB CR via
  # `mariadb.externalRef`.
  externalRef:
    name: ""        # e.g. "mariadb"
    namespace: ""   # e.g. "ezscale"
  image: mariadb:11.4
  replicas: 1
  storage:
    size: 5Gi
    storageClassName: local-path
  rootPasswordSecret: ""    # if empty, chart generates a random secret
  database: ezscale_billing
  username: ezscale_billing_app

# --- In-cluster Valkey (StatefulSet) ---
valkey:
  enabled: true
  image: valkey/valkey:9-alpine
  password: ""              # if empty, chart generates a random secret
  maxmemory: "1gb"
  storage:
    size: 5Gi
    storageClassName: local-path

# --- Migration Job (Helm hook) ---
migrate:
  enabled: true
  seed: false
  seedClass: ProductionSeeder

# --- Ingress (Traefik IngressRoute) ---
ingressRoute:
  enabled: false
  hosts:
    - ezscale.cloud
    - account.ezscale.cloud
    - admin.ezscale.cloud
  tls:
    secretName: ezscale-website-tls
    issuerName: letsencrypt
  middlewares:
    cloudflarewarp:
      enabled: false
      namespace: kube-system
      name: cloudflarewarp
    httpToHttps:
      enabled: false
      namespace: kube-system
      name: http-to-https

# --- Service ---
service:
  type: ClusterIP
  port: 80

# --- Non-secret env vars (rendered into ConfigMap) ---
env:
  APP_NAME: "EZSCALE Billing"
  APP_ENV: production
  APP_DEBUG: "false"
  APP_URL: https://ezscale.cloud
  APP_MAINTENANCE_DRIVER: file
  LOG_CHANNEL: stack
  LOG_STACK: single
  LOG_LEVEL: info
  DB_CONNECTION: mysql
  DB_PORT: "3306"
  DB_DATABASE: ezscale_billing
  DB_USERNAME: ezscale_billing_app
  REDIS_CLIENT: phpredis
  REDIS_PORT: "6379"
  SESSION_DRIVER: redis
  SESSION_LIFETIME: "120"
  SESSION_DOMAIN: .ezscale.cloud
  CACHE_STORE: redis
  QUEUE_CONNECTION: redis
  BROADCAST_CONNECTION: log
  FILESYSTEM_DISK: s3
  MAIL_MAILER: smtp
  AWS_DEFAULT_REGION: us-east-1
  AWS_USE_PATH_STYLE_ENDPOINT: "true"

# --- Secret references (chart does NOT generate APP_KEY or Passport keys) ---
secret:
  # When false, chart assumes a Secret named `secret.existingSecretName` is
  # already present. This is the production path.
  create: false
  existingSecretName: ezscale-website-secrets
  # Used only when create=true (local dev convenience).
  values: {}

# --- Probes ---
healthCheck:
  livenessPath: /up
  readinessPath: /up
  initialDelaySeconds: 15
  periodSeconds: 15
  timeoutSeconds: 5
  failureThreshold: 3
  • Step 3: Create templates/_helpers.tpl
{{/* Common name helpers */}}
{{- define "ezscale-website.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "ezscale-website.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}

{{- define "ezscale-website.labels" -}}
app.kubernetes.io/name: {{ include "ezscale-website.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{- end -}}

{{- define "ezscale-website.selectorLabels" -}}
app.kubernetes.io/name: {{ include "ezscale-website.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

{{/*
Image reference for a given role (app/horizon/scheduler).
Usage: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }}
*/}}
{{- define "ezscale-website.image" -}}
{{- $ctx := .ctx -}}
{{- printf "%s/%s:%s-%s" $ctx.Values.image.registry $ctx.Values.image.repository .role $ctx.Values.image.tag -}}
{{- end -}}

{{/* Secret name (existing or generated) */}}
{{- define "ezscale-website.secretName" -}}
{{- if .Values.secret.create -}}
{{- include "ezscale-website.fullname" . -}}-secrets
{{- else -}}
{{- .Values.secret.existingSecretName -}}
{{- end -}}
{{- end -}}

{{/* DB host — points at in-cluster MariaDB or external one */}}
{{- define "ezscale-website.dbHost" -}}
{{- if .Values.mariadb.enabled -}}
{{ include "ezscale-website.fullname" . }}-mariadb
{{- else -}}
{{- $ref := .Values.mariadb.externalRef -}}
{{- printf "%s.%s.svc.cluster.local" $ref.name $ref.namespace -}}
{{- end -}}
{{- end -}}

{{/* Redis host */}}
{{- define "ezscale-website.redisHost" -}}
{{ include "ezscale-website.fullname" . }}-valkey
{{- end -}}
  • Step 4: Create .helmignore
.DS_Store
.git/
.gitignore
*.tmproj
.vscode/
*.swp
*.tmp
*.bak
README.md.bak
  • Step 5: Lint the chart
cd /home/andrew/local_projects/website
helm lint helm/ezscale-website

Expected: 1 chart(s) linted, 0 chart(s) failed. Templates directory is empty so far — that's fine for lint.

  • Step 6: Commit
git add helm/ezscale-website/
git commit -m "feat(helm): chart skeleton (Chart.yaml, values, helpers)

Initial scaffold for the ezscale-website chart. Defaults assume
self-contained local dev (in-cluster MariaDB + Valkey). Production
overrides will live in values-us-prod.yaml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 5: ConfigMap and Secret placeholder templates

Files:

  • Create: helm/ezscale-website/templates/configmap.yaml

  • Create: helm/ezscale-website/templates/secret.yaml

  • Step 1: Create configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "ezscale-website.fullname" . }}-env
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
data:
  {{- range $key, $value := .Values.env }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}
  DB_HOST: {{ include "ezscale-website.dbHost" . | quote }}
  REDIS_HOST: {{ include "ezscale-website.redisHost" . | quote }}
  • Step 2: Create secret.yaml
{{- if .Values.secret.create -}}
apiVersion: v1
kind: Secret
metadata:
  name: {{ include "ezscale-website.fullname" . }}-secrets
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
type: Opaque
stringData:
  {{- range $key, $value := .Values.secret.values }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}
{{- end -}}
  • Step 3: Render & validate
cd /home/andrew/local_projects/website
helm template ezscale-website helm/ezscale-website > /tmp/render.yaml
kubectl apply --dry-run=client -f /tmp/render.yaml

Expected: ConfigMap dry-run passes (configmap/release-name-ezscale-website-env created (dry run)). No Secret because secret.create=false by default.

  • Step 4: Commit
git add helm/ezscale-website/templates/configmap.yaml helm/ezscale-website/templates/secret.yaml
git commit -m "feat(helm): ConfigMap + Secret templates

ConfigMap renders all non-secret env vars including dynamic DB_HOST
and REDIS_HOST. Secret template only renders when secret.create=true
(dev convenience); production references an existing Secret.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 6: Service template

Files:

  • Create: helm/ezscale-website/templates/service.yaml

  • Step 1: Create service.yaml

apiVersion: v1
kind: Service
metadata:
  name: {{ include "ezscale-website.fullname" . }}
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: http
      protocol: TCP
      name: http
  selector:
    {{- include "ezscale-website.selectorLabels" . | nindent 4 }}
    app.kubernetes.io/component: app
  • Step 2: Verify rendering
helm template ezscale-website helm/ezscale-website | grep -A 12 'kind: Service'

Expected: a Service block with targetPort: http and the right selector.

  • Step 3: Commit
git add helm/ezscale-website/templates/service.yaml
git commit -m "feat(helm): Service template (ClusterIP, port 80 → http)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 7: App Deployment (php-fpm + nginx sidecar)

Files:

  • Create: helm/ezscale-website/templates/deployment-app.yaml
  • Create: helm/ezscale-website/templates/configmap-nginx.yaml

Context: The pod runs two containers (nginx + app) sharing source via an emptyDir populated by an init container. nginx vhost is rendered into a ConfigMap separate from the env ConfigMap so it can be edited without restart-cycling secrets.

  • Step 1: Create configmap-nginx.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "ezscale-website.fullname" . }}-nginx
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
data:
  default.conf: |
    server {
        listen 80 default_server;
        server_name _;
        root /var/www/html/public;
        index index.php index.html;

        client_max_body_size 50M;
        charset utf-8;

        add_header X-Frame-Options "SAMEORIGIN";
        add_header X-Content-Type-Options "nosniff";
        add_header Referrer-Policy "strict-origin-when-cross-origin";

        location = /favicon.ico { access_log off; log_not_found off; }
        location = /robots.txt  { access_log off; log_not_found off; }

        location / {
            try_files $uri $uri/ /index.php?$query_string;
        }

        location ~ \.php$ {
            try_files $uri =404;
            fastcgi_split_path_info ^(.+\.php)(/.+)$;
            fastcgi_pass 127.0.0.1:9000;
            fastcgi_index index.php;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
            fastcgi_param DOCUMENT_ROOT $realpath_root;
            fastcgi_param HTTP_PROXY "";
            fastcgi_param HTTPS $http_x_forwarded_proto;
            fastcgi_buffers 16 16k;
            fastcgi_buffer_size 32k;
            fastcgi_read_timeout 300;
        }

        location ~ /\.(?!well-known).* {
            deny all;
            access_log off;
            log_not_found off;
        }
    }
  • Step 2: Create deployment-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "ezscale-website.fullname" . }}-app
  labels:
    {{- include "ezscale-website.labels" . | nindent 4 }}
    app.kubernetes.io/component: app
spec:
  {{- if not .Values.app.autoscaling.enabled }}
  replicas: {{ .Values.app.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "ezscale-website.selectorLabels" . | nindent 6 }}
      app.kubernetes.io/component: app
  template:
    metadata:
      labels:
        {{- include "ezscale-website.selectorLabels" . | nindent 8 }}
        app.kubernetes.io/component: app
      annotations:
        # Restart pods when env or nginx config changes
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        checksum/nginx: {{ include (print $.Template.BasePath "/configmap-nginx.yaml") . | sha256sum }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      initContainers:
        - name: copy-source
          image: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }}
          command:
            - sh
            - -c
            - |
              cp -a /var/www/html/. /shared/
          volumeMounts:
            - name: shared
              mountPath: /shared
      containers:
        - name: nginx
          image: nginx:1.30-alpine
          ports:
            - name: http
              containerPort: 80
          volumeMounts:
            - name: shared
              mountPath: /var/www/html
              readOnly: true
            - name: nginx-config
              mountPath: /etc/nginx/conf.d
              readOnly: true
          livenessProbe:
            httpGet:
              path: {{ .Values.healthCheck.livenessPath }}
              port: http
            initialDelaySeconds: {{ .Values.healthCheck.initialDelaySeconds }}
            periodSeconds: {{ .Values.healthCheck.periodSeconds }}
            timeoutSeconds: {{ .Values.healthCheck.timeoutSeconds }}
            failureThreshold: {{ .Values.healthCheck.failureThreshold }}
          readinessProbe:
            httpGet:
              path: {{ .Values.healthCheck.readinessPath }}
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
        - name: app
          image: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }}
          ports:
            - name: php-fpm
              containerPort: 9000
          envFrom:
            - configMapRef:
                name: {{ include "ezscale-website.fullname" . }}-env
            - secretRef:
                name: {{ include "ezscale-website.secretName" . }}
          volumeMounts:
            - name: shared
              mountPath: /var/www/html
            - name: oauth-keys
              mountPath: /var/www/html/secrets
              readOnly: true
          resources:
            {{- toYaml .Values.app.resources | nindent 12 }}
      volumes:
        - name: shared
          emptyDir: {}
        - name: nginx-config
          configMap:
            name: {{ include "ezscale-website.fullname" . }}-nginx
        - name: oauth-keys
          secret:
            secretName: {{ include "ezscale-website.secretName" . }}
            items:
              - key: oauth-private.key
                path: oauth-private.key
              - key: oauth-public.key
                path: oauth-public.key
            optional: true
  • Step 3: Render & validate
helm template ezscale-website helm/ezscale-website \
  --set secret.existingSecretName=test-secret \
  > /tmp/render.yaml
kubectl apply --dry-run=client -f /tmp/render.yaml

Expected: deployment dry-runs cleanly. Confirm two containers + one init container + one Service in the rendered output.

  • Step 4: Commit
git add helm/ezscale-website/templates/deployment-app.yaml helm/ezscale-website/templates/configmap-nginx.yaml
git commit -m "feat(helm): app Deployment (nginx + php-fpm sidecar)

Two-container pod sharing source via emptyDir populated by init
container. Nginx vhost in a separate ConfigMap. OAuth keys mounted
from the chart Secret as files under /var/www/html/secrets/, copied
into storage/ by the prod entrypoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 8: HPA for app

Files:

  • Create: helm/ezscale-website/templates/hpa-app.yaml

  • Step 1: Create hpa-app.yaml

{{- if .Values.app.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "ezscale-website.fullname" . }}-app
  labels:
    {{- include "ezscale-website.labels" . | nindent 4 }}
    app.kubernetes.io/component: app
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "ezscale-website.fullname" . }}-app
  minReplicas: {{ .Values.app.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.app.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.app.autoscaling.targetCPU }}
{{- end }}
  • Step 2: Verify HPA renders only when enabled
helm template ezscale-website helm/ezscale-website | grep -c 'kind: HorizontalPodAutoscaler'
helm template ezscale-website helm/ezscale-website --set app.autoscaling.enabled=true | grep -c 'kind: HorizontalPodAutoscaler'

Expected: 0 then 1.

  • Step 3: Commit
git add helm/ezscale-website/templates/hpa-app.yaml
git commit -m "feat(helm): HPA for app deployment (toggleable, CPU-based)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 9: Horizon Deployment

Files:

  • Create: helm/ezscale-website/templates/deployment-horizon.yaml

  • Step 1: Create deployment-horizon.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "ezscale-website.fullname" . }}-horizon
  labels:
    {{- include "ezscale-website.labels" . | nindent 4 }}
    app.kubernetes.io/component: horizon
spec:
  replicas: {{ .Values.horizon.replicaCount }}
  # Horizon needs SIGTERM + drain time. Don't run two replicas during update.
  strategy:
    type: Recreate
  selector:
    matchLabels:
      {{- include "ezscale-website.selectorLabels" . | nindent 6 }}
      app.kubernetes.io/component: horizon
  template:
    metadata:
      labels:
        {{- include "ezscale-website.selectorLabels" . | nindent 8 }}
        app.kubernetes.io/component: horizon
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      terminationGracePeriodSeconds: 60
      containers:
        - name: horizon
          image: {{ include "ezscale-website.image" (dict "ctx" . "role" "horizon") }}
          envFrom:
            - configMapRef:
                name: {{ include "ezscale-website.fullname" . }}-env
            - secretRef:
                name: {{ include "ezscale-website.secretName" . }}
          volumeMounts:
            - name: oauth-keys
              mountPath: /var/www/html/secrets
              readOnly: true
          resources:
            {{- toYaml .Values.horizon.resources | nindent 12 }}
          livenessProbe:
            exec:
              command:
                - php
                - artisan
                - horizon:status
            initialDelaySeconds: 30
            periodSeconds: 30
            timeoutSeconds: 10
      volumes:
        - name: oauth-keys
          secret:
            secretName: {{ include "ezscale-website.secretName" . }}
            items:
              - key: oauth-private.key
                path: oauth-private.key
              - key: oauth-public.key
                path: oauth-public.key
            optional: true
  • Step 2: Validate
helm template ezscale-website helm/ezscale-website \
  --set secret.existingSecretName=test-secret \
  > /tmp/render.yaml
kubectl apply --dry-run=client -f /tmp/render.yaml

Expected: clean dry-run.

  • Step 3: Commit
git add helm/ezscale-website/templates/deployment-horizon.yaml
git commit -m "feat(helm): Horizon deployment (Recreate strategy, 60s grace)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 10: Scheduler Deployment

Files:

  • Create: helm/ezscale-website/templates/deployment-scheduler.yaml

  • Step 1: Create deployment-scheduler.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "ezscale-website.fullname" . }}-scheduler
  labels:
    {{- include "ezscale-website.labels" . | nindent 4 }}
    app.kubernetes.io/component: scheduler
spec:
  # Single replica only — running two schedule:work instances doubles tasks.
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      {{- include "ezscale-website.selectorLabels" . | nindent 6 }}
      app.kubernetes.io/component: scheduler
  template:
    metadata:
      labels:
        {{- include "ezscale-website.selectorLabels" . | nindent 8 }}
        app.kubernetes.io/component: scheduler
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      containers:
        - name: scheduler
          image: {{ include "ezscale-website.image" (dict "ctx" . "role" "scheduler") }}
          envFrom:
            - configMapRef:
                name: {{ include "ezscale-website.fullname" . }}-env
            - secretRef:
                name: {{ include "ezscale-website.secretName" . }}
          resources:
            {{- toYaml .Values.scheduler.resources | nindent 12 }}
  • Step 2: Validate
helm template ezscale-website helm/ezscale-website \
  --set secret.existingSecretName=test-secret | kubectl apply --dry-run=client -f -

Expected: clean dry-run.

  • Step 3: Commit
git add helm/ezscale-website/templates/deployment-scheduler.yaml
git commit -m "feat(helm): scheduler deployment (single replica, schedule:work)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 11: Migration Job (Helm hook)

Files:

  • Create: helm/ezscale-website/templates/job-migrate.yaml

  • Step 1: Create job-migrate.yaml

{{- if .Values.migrate.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "ezscale-website.fullname" . }}-migrate
  labels:
    {{- include "ezscale-website.labels" . | nindent 4 }}
    app.kubernetes.io/component: migrate
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  backoffLimit: 1
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        {{- include "ezscale-website.selectorLabels" . | nindent 8 }}
        app.kubernetes.io/component: migrate
    spec:
      restartPolicy: Never
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      containers:
        - name: migrate
          image: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }}
          command:
            - sh
            - -c
            - |
              set -e
              php artisan migrate --force --no-interaction
              {{- if .Values.migrate.seed }}
              php artisan db:seed --class={{ .Values.migrate.seedClass }} --force --no-interaction
              {{- end }}
          envFrom:
            - configMapRef:
                name: {{ include "ezscale-website.fullname" . }}-env
            - secretRef:
                name: {{ include "ezscale-website.secretName" . }}
{{- end }}
  • Step 2: Validate
helm template ezscale-website helm/ezscale-website \
  --set secret.existingSecretName=test-secret | grep -A 5 'kind: Job'

Expected: a Job block with the helm.sh/hook annotations.

  • Step 3: Commit
git add helm/ezscale-website/templates/job-migrate.yaml
git commit -m "feat(helm): pre-install/pre-upgrade migration Job

Helm hook runs migrate (and optionally seed) before any pod rolls.
If the Job fails, helm upgrade aborts and the previous ReplicaSet
keeps serving traffic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 12: MariaDB CRDs (Database, User, Grant)

Files:

  • Create: helm/ezscale-website/templates/mariadb-database.yaml
  • Create: helm/ezscale-website/templates/mariadb-user.yaml
  • Create: helm/ezscale-website/templates/mariadb-grant.yaml

Context: When mariadb.enabled=true, the CRDs reference the in-cluster MariaDB instance this chart deploys (Task 13). When mariadb.enabled=false, they reference an external one specified in mariadb.externalRef. Either way, the chart's app pods authenticate as the user this declares.

  • Step 1: Create mariadb-database.yaml
apiVersion: k8s.mariadb.com/v1alpha1
kind: Database
metadata:
  name: {{ include "ezscale-website.fullname" . }}-db
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
spec:
  mariaDbRef:
    {{- if .Values.mariadb.enabled }}
    name: {{ include "ezscale-website.fullname" . }}-mariadb
    {{- else }}
    name: {{ .Values.mariadb.externalRef.name }}
    namespace: {{ .Values.mariadb.externalRef.namespace }}
    {{- end }}
  characterSet: utf8mb4
  collate: utf8mb4_unicode_ci
  name: {{ .Values.mariadb.database }}
  • Step 2: Create mariadb-user.yaml
apiVersion: k8s.mariadb.com/v1alpha1
kind: User
metadata:
  name: {{ include "ezscale-website.fullname" . }}-user
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
spec:
  # spec.name overrides metadata.name as the SQL identifier — needed because
  # k8s resource names can't contain underscores but our SQL username can.
  name: {{ .Values.mariadb.username }}
  mariaDbRef:
    {{- if .Values.mariadb.enabled }}
    name: {{ include "ezscale-website.fullname" . }}-mariadb
    {{- else }}
    name: {{ .Values.mariadb.externalRef.name }}
    namespace: {{ .Values.mariadb.externalRef.namespace }}
    {{- end }}
  passwordSecretKeyRef:
    name: {{ include "ezscale-website.secretName" . }}
    key: DB_PASSWORD
  host: "%"
  maxUserConnections: 50
  • Step 3: Create mariadb-grant.yaml
apiVersion: k8s.mariadb.com/v1alpha1
kind: Grant
metadata:
  name: {{ include "ezscale-website.fullname" . }}-grant
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
spec:
  mariaDbRef:
    {{- if .Values.mariadb.enabled }}
    name: {{ include "ezscale-website.fullname" . }}-mariadb
    {{- else }}
    name: {{ .Values.mariadb.externalRef.name }}
    namespace: {{ .Values.mariadb.externalRef.namespace }}
    {{- end }}
  username: {{ .Values.mariadb.username }}
  host: "%"
  privileges:
    - "ALL PRIVILEGES"
  database: {{ .Values.mariadb.database }}
  table: "*"
  • Step 4: Validate
helm template ezscale-website helm/ezscale-website | grep -E 'kind: (Database|User|Grant)'

Expected: three matches. Note kubectl apply --dry-run=client will fail on these since the CRDs aren't installed in the local kubectl context — that's expected. The template syntax check via helm template is enough here.

  • Step 5: Commit
git add helm/ezscale-website/templates/mariadb-database.yaml \
        helm/ezscale-website/templates/mariadb-user.yaml \
        helm/ezscale-website/templates/mariadb-grant.yaml
git commit -m "feat(helm): mariadb-operator Database/User/Grant CRDs

When mariadb.enabled=true, references the in-cluster MariaDB this
chart deploys. When false, references an external CR via
mariadb.externalRef. Privileges scoped to the website's database
only — no global ALL PRIVILEGES.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 13: MariaDB instance template (toggleable)

Files:

  • Create: helm/ezscale-website/templates/mariadb-instance.yaml

  • Step 1: Create mariadb-instance.yaml

{{- if .Values.mariadb.enabled }}
{{- $rootSecretName := default (printf "%s-mariadb-root" (include "ezscale-website.fullname" .)) .Values.mariadb.rootPasswordSecret }}
{{- if not .Values.mariadb.rootPasswordSecret }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ $rootSecretName }}
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
  annotations:
    helm.sh/resource-policy: keep
type: Opaque
stringData:
  password: {{ randAlphaNum 32 | quote }}
---
{{- end }}
apiVersion: k8s.mariadb.com/v1alpha1
kind: MariaDB
metadata:
  name: {{ include "ezscale-website.fullname" . }}-mariadb
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
spec:
  image: {{ .Values.mariadb.image }}
  rootPasswordSecretKeyRef:
    name: {{ $rootSecretName }}
    key: password
    generate: false
  replicas: {{ .Values.mariadb.replicas }}
  storage:
    size: {{ .Values.mariadb.storage.size }}
    storageClassName: {{ .Values.mariadb.storage.storageClassName }}
{{- end }}

Note about the helm.sh/resource-policy: keep annotation on the generated root password Secret: it prevents helm uninstall from deleting the root password, which would orphan the data PVC. For local dev clean slates use kubectl delete secret <name> manually.

  • Step 2: Validate
helm template ezscale-website helm/ezscale-website > /tmp/render.yaml
grep -E 'kind: MariaDB|generate: false' /tmp/render.yaml

Expected: one kind: MariaDB plus generate: false.

  • Step 3: Commit
git add helm/ezscale-website/templates/mariadb-instance.yaml
git commit -m "feat(helm): in-cluster MariaDB CR (toggleable for dev)

Renders only when mariadb.enabled=true. Generates a random root
password Secret with helm.sh/resource-policy=keep so uninstall
doesn't orphan the data volume.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 14: Valkey StatefulSet template

Files:

  • Create: helm/ezscale-website/templates/statefulset-valkey.yaml

  • Step 1: Create statefulset-valkey.yaml

{{- if .Values.valkey.enabled }}
{{- $secretName := printf "%s-valkey" (include "ezscale-website.fullname" .) }}
{{- if .Values.valkey.password }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ $secretName }}
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
  annotations:
    helm.sh/resource-policy: keep
type: Opaque
stringData:
  password: {{ .Values.valkey.password | quote }}
---
{{- end }}
apiVersion: v1
kind: Service
metadata:
  name: {{ include "ezscale-website.redisHost" . }}
  labels:
    {{- include "ezscale-website.labels" . | nindent 4 }}
    app.kubernetes.io/component: valkey
spec:
  type: ClusterIP
  ports:
    - port: 6379
      targetPort: redis
      name: redis
  selector:
    {{- include "ezscale-website.selectorLabels" . | nindent 4 }}
    app.kubernetes.io/component: valkey
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: {{ include "ezscale-website.redisHost" . }}
  labels:
    {{- include "ezscale-website.labels" . | nindent 4 }}
    app.kubernetes.io/component: valkey
spec:
  serviceName: {{ include "ezscale-website.redisHost" . }}
  replicas: 1
  selector:
    matchLabels:
      {{- include "ezscale-website.selectorLabels" . | nindent 6 }}
      app.kubernetes.io/component: valkey
  template:
    metadata:
      labels:
        {{- include "ezscale-website.selectorLabels" . | nindent 8 }}
        app.kubernetes.io/component: valkey
    spec:
      containers:
        - name: valkey
          image: {{ .Values.valkey.image }}
          command:
            - valkey-server
            - --appendonly
            - "yes"
            - --maxmemory
            - {{ .Values.valkey.maxmemory | quote }}
            - --maxmemory-policy
            - allkeys-lru
            {{- if .Values.valkey.password }}
            - --requirepass
            - $(REDIS_PASSWORD)
            {{- end }}
          {{- if .Values.valkey.password }}
          env:
            - name: REDIS_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ $secretName }}
                  key: password
          {{- end }}
          ports:
            - name: redis
              containerPort: 6379
          volumeMounts:
            - name: data
              mountPath: /data
          livenessProbe:
            exec:
              command: ["valkey-cli", "ping"]
            initialDelaySeconds: 10
            periodSeconds: 10
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: {{ .Values.valkey.storage.storageClassName }}
        resources:
          requests:
            storage: {{ .Values.valkey.storage.size }}
{{- end }}
  • Step 2: Validate
helm template ezscale-website helm/ezscale-website > /tmp/render.yaml
grep -E 'kind: (Service|StatefulSet)' /tmp/render.yaml

Expected: at least one Service (the app) + one Service (valkey) + one StatefulSet.

  • Step 3: Commit
git add helm/ezscale-website/templates/statefulset-valkey.yaml
git commit -m "feat(helm): in-cluster Valkey StatefulSet (toggleable)

AOF persistence + LRU eviction + optional password. PVC for the
queue data so Horizon doesn't lose pending jobs on pod restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 15: IngressRoute and Certificate templates

Files:

  • Create: helm/ezscale-website/templates/ingressroute.yaml

  • Create: helm/ezscale-website/templates/certificate.yaml

  • Step 1: Create ingressroute.yaml

{{- if .Values.ingressRoute.enabled }}
{{- $hostMatch := "" -}}
{{- range $i, $h := .Values.ingressRoute.hosts -}}
{{- if eq $i 0 -}}
{{- $hostMatch = printf "Host(`%s`)" $h -}}
{{- else -}}
{{- $hostMatch = printf "%s || Host(`%s`)" $hostMatch $h -}}
{{- end -}}
{{- end }}
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: {{ include "ezscale-website.fullname" . }}-https
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
spec:
  entryPoints: [websecure]
  routes:
    - kind: Rule
      match: {{ $hostMatch }}
      middlewares:
        {{- if .Values.ingressRoute.middlewares.cloudflarewarp.enabled }}
        - name: {{ .Values.ingressRoute.middlewares.cloudflarewarp.name }}
          namespace: {{ .Values.ingressRoute.middlewares.cloudflarewarp.namespace }}
        {{- end }}
      services:
        - name: {{ include "ezscale-website.fullname" . }}
          port: {{ .Values.service.port }}
  tls:
    secretName: {{ .Values.ingressRoute.tls.secretName }}
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: {{ include "ezscale-website.fullname" . }}-http
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
spec:
  entryPoints: [web]
  routes:
    - kind: Rule
      match: {{ $hostMatch }}
      middlewares:
        {{- if .Values.ingressRoute.middlewares.httpToHttps.enabled }}
        - name: {{ .Values.ingressRoute.middlewares.httpToHttps.name }}
          namespace: {{ .Values.ingressRoute.middlewares.httpToHttps.namespace }}
        {{- end }}
      services:
        - name: {{ include "ezscale-website.fullname" . }}
          port: {{ .Values.service.port }}
{{- end }}
  • Step 2: Create certificate.yaml
{{- if .Values.ingressRoute.enabled }}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: {{ .Values.ingressRoute.tls.secretName }}
  labels: {{- include "ezscale-website.labels" . | nindent 4 }}
spec:
  secretName: {{ .Values.ingressRoute.tls.secretName }}
  issuerRef:
    kind: ClusterIssuer
    name: {{ .Values.ingressRoute.tls.issuerName }}
  dnsNames:
    {{- range .Values.ingressRoute.hosts }}
    - {{ . | quote }}
    {{- end }}
{{- end }}
  • Step 3: Validate
helm template ezscale-website helm/ezscale-website \
  --set ingressRoute.enabled=true \
  --set ingressRoute.middlewares.cloudflarewarp.enabled=true \
  --set ingressRoute.middlewares.httpToHttps.enabled=true \
  | grep -E 'kind: (IngressRoute|Certificate)'

Expected: 2 IngressRoutes + 1 Certificate.

  • Step 4: Commit
git add helm/ezscale-website/templates/ingressroute.yaml \
        helm/ezscale-website/templates/certificate.yaml
git commit -m "feat(helm): Traefik IngressRoute + cert-manager Certificate

Two IngressRoutes (web → http-to-https redirect, websecure → app)
covering all configured hosts. Certificate covers all hosts as SANs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 16: Environment values files

Files:

  • Create: helm/ezscale-website/values-local.yaml

  • Create: helm/ezscale-website/values-us-prod.yaml

  • Step 1: Create values-local.yaml

# ===========================================================================
# Local k3d/minikube cluster — fully self-contained.
# Prerequisite: mariadb-operator installed in the cluster.
#   helm install mariadb-operator -n mariadb-operator --create-namespace \
#     mariadb-operator/mariadb-operator
# ===========================================================================

image:
  tag: latest
  pullPolicy: Always

imagePullSecrets: []   # no registry auth needed for locally-built images

app:
  replicaCount: 1
  resources:
    requests: { cpu: 100m, memory: 256Mi }

mariadb:
  enabled: true
  replicas: 1
  storage:
    size: 5Gi
    storageClassName: local-path

valkey:
  enabled: true
  storage:
    size: 1Gi
    storageClassName: local-path

migrate:
  enabled: true
  seed: true
  seedClass: DemoDataSeeder

ingressRoute:
  enabled: false       # local uses port-forward, not Traefik

# Local dev: chart generates a random APP_KEY on first install.
# This is OK in local because there's no encrypted prod data to lose.
# In production this MUST be `secret.create=false`.
secret:
  create: true
  existingSecretName: ""
  values:
    APP_KEY: "base64:CHANGEME_GENERATE_VIA_PHP_ARTISAN_KEY_GENERATE_SHOW"
    DB_PASSWORD: "local_dev_password"
    AWS_ACCESS_KEY_ID: ""
    AWS_SECRET_ACCESS_KEY: ""
    STRIPE_KEY: ""
    STRIPE_SECRET: ""

env:
  APP_ENV: local
  APP_DEBUG: "true"
  APP_URL: http://localhost
  LOG_LEVEL: debug
  FILESYSTEM_DISK: local
  MAIL_MAILER: log
  SESSION_DOMAIN: ""

Note: APP_KEY placeholder must be replaced before first install. Run php artisan key:generate --show against any working Laravel checkout, paste the result here.

  • Step 2: Create values-us-prod.yaml
# ===========================================================================
# Production: ezs-us-east-prod-01.node.ezscale.tech
# Namespace: ezscale (shared with mariadb instance + ezscale_api)
# ===========================================================================

image:
  registry: git.ezscale.cloud
  repository: ezscale/website
  tag: ""              # SET via --set image.tag=v0.1.0 at deploy time
  pullPolicy: IfNotPresent

imagePullSecrets:
  - name: gitea-registry

app:
  replicaCount: 2
  autoscaling:
    enabled: true
    minReplicas: 2
    maxReplicas: 8
    targetCPU: 70
  resources:
    requests: { cpu: 200m, memory: 512Mi }
    limits:   { cpu: 1500m, memory: 1536Mi }

horizon:
  replicaCount: 1
  resources:
    requests: { cpu: 200m, memory: 512Mi }
    limits:   { cpu: 1000m, memory: 1Gi }

scheduler:
  replicaCount: 1
  resources:
    requests: { cpu: 50m, memory: 128Mi }

# Reuse the cluster's existing replicated MariaDB.
mariadb:
  enabled: false
  externalRef:
    name: mariadb
    namespace: ezscale
  database: ezscale_billing
  username: ezscale_billing_app

# Per-app Valkey for sessions/cache/queue.
valkey:
  enabled: true
  storage:
    size: 10Gi
    storageClassName: longhorn

migrate:
  enabled: true
  seed: false

ingressRoute:
  enabled: true
  hosts:
    - ezscale.cloud
    - account.ezscale.cloud
    - admin.ezscale.cloud
  tls:
    secretName: ezscale-website-tls
    issuerName: letsencrypt
  middlewares:
    cloudflarewarp:
      enabled: true
      namespace: kube-system
      name: cloudflarewarp
    httpToHttps:
      enabled: true
      namespace: kube-system
      name: http-to-https

# Production NEVER lets the chart generate APP_KEY. Bootstrap procedure
# in helm/ezscale-website/README.md.
secret:
  create: false
  existingSecretName: ezscale-website-secrets

env:
  APP_NAME: "EZSCALE Billing"
  APP_ENV: production
  APP_DEBUG: "false"
  APP_URL: https://ezscale.cloud
  LOG_LEVEL: warning
  FILESYSTEM_DISK: s3
  AWS_BUCKET: ezscale-website-prod
  AWS_DEFAULT_REGION: us-east-1
  AWS_ENDPOINT: https://gateway.storjshare.io
  AWS_USE_PATH_STYLE_ENDPOINT: "true"
  SESSION_DRIVER: redis
  SESSION_DOMAIN: .ezscale.cloud
  CACHE_STORE: redis
  QUEUE_CONNECTION: redis
  MAIL_MAILER: smtp
  TRUSTED_PROXIES: "*"
  • Step 3: Render both and validate template-only
cd /home/andrew/local_projects/website
helm template ezscale-website helm/ezscale-website -f helm/ezscale-website/values-local.yaml > /tmp/local.yaml
helm template ezscale-website helm/ezscale-website -f helm/ezscale-website/values-us-prod.yaml --set image.tag=v0.1.0 > /tmp/prod.yaml
echo "Local: $(wc -l < /tmp/local.yaml) lines"
echo "Prod:  $(wc -l < /tmp/prod.yaml) lines"
grep -E '^kind: ' /tmp/prod.yaml | sort | uniq -c

Expected: prod render shows ConfigMap, Database, Deployment×3, Grant, IngressRoute×2, Job, Service×2, StatefulSet, User, Certificate, HorizontalPodAutoscaler. Not MariaDB (because external) and not the local-only generated Secret.

  • Step 4: Commit
git add helm/ezscale-website/values-local.yaml helm/ezscale-website/values-us-prod.yaml
git commit -m "feat(helm): values-local + values-us-prod

Local: in-cluster MariaDB + Valkey, port-forward instead of ingress,
chart-generated APP_KEY (dev only).
Prod: external MariaDB (ezscale ns), Longhorn-backed Valkey, Traefik
IngressRoute with cloudflarewarp + cert-manager TLS, image.tag set
at deploy time, secret pre-created out-of-band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 17: Helm chart README + bootstrap procedure

Files:

  • Create: helm/ezscale-website/README.md

  • Step 1: Create helm/ezscale-website/README.md

# ezscale-website Helm Chart

Production deployment for the EZSCALE billing platform Laravel app.

## Environments

| Env | Values file | Notes |
|---|---|---|
| Local k3d | `values-local.yaml` | Self-contained: in-cluster MariaDB + Valkey. No ingress (use port-forward). |
| US prod | `values-us-prod.yaml` | Connects to existing `mariadb` CR in `ezscale` ns; per-app Valkey on Longhorn. |

## Prerequisites (us-prod)

The `ezscale` namespace must already have:
- mariadb-operator installed cluster-wide (provides `MariaDB`/`Database`/`User`/`Grant` CRDs)
- A `MariaDB` CR named `mariadb`
- cert-manager `ClusterIssuer` named `letsencrypt`
- Traefik with `cloudflarewarp` and `http-to-https` middlewares in `kube-system`
- Image-pull `Secret` named `gitea-registry`

These are all already provisioned per `infrastructure/kubernetes/`.

## First-time bootstrap (us-prod)

This step is run **once** per environment, never repeated. The Secret it creates lives forever — re-running it would invalidate every encrypted DB column and every issued OAuth token.

```bash
# 1. Generate APP_KEY locally (any Laravel checkout works)
APP_KEY=$(cd /tmp && composer create-project laravel/laravel _key_gen --quiet --no-install \
  && cd _key_gen && composer install --quiet \
  && php artisan key:generate --show)

# 2. Generate Passport keys
TMP_KEYS=$(mktemp -d)
openssl genrsa -out "$TMP_KEYS/oauth-private.key" 4096
openssl rsa -in "$TMP_KEYS/oauth-private.key" -pubout -out "$TMP_KEYS/oauth-public.key"

# 3. Gather production secrets. Read each one and store in shell vars.
DB_PASS=$(openssl rand -base64 32 | tr -d '/=+' | cut -c1-32)
read -sp "STRIPE_KEY (pk_live_...): " STRIPE_KEY; echo
read -sp "STRIPE_SECRET (sk_live_...): " STRIPE_SECRET; echo
read -sp "STRIPE_WEBHOOK_SECRET (whsec_...): " STRIPE_WEBHOOK; echo
read -sp "PAYPAL_CLIENT_ID: " PAYPAL_ID; echo
read -sp "PAYPAL_CLIENT_SECRET: " PAYPAL_SECRET; echo
read -sp "AWS_ACCESS_KEY_ID (Storj): " AWS_KEY; echo
read -sp "AWS_SECRET_ACCESS_KEY (Storj): " AWS_SECRET; echo
read -sp "MAIL_USERNAME (SMTP): " MAIL_USER; echo
read -sp "MAIL_PASSWORD (SMTP): " MAIL_PASS; echo
read -sp "VIRTFUSION_API_KEY: " VF_KEY; echo
read -sp "PTERODACTYL_APP_KEY: " PTERO_KEY; echo

# 4. Create the Secret in the ezscale namespace
kubectl create secret generic ezscale-website-secrets \
  --namespace ezscale \
  --from-literal=APP_KEY="$APP_KEY" \
  --from-literal=DB_PASSWORD="$DB_PASS" \
  --from-literal=STRIPE_KEY="$STRIPE_KEY" \
  --from-literal=STRIPE_SECRET="$STRIPE_SECRET" \
  --from-literal=STRIPE_WEBHOOK_SECRET="$STRIPE_WEBHOOK" \
  --from-literal=PAYPAL_CLIENT_ID="$PAYPAL_ID" \
  --from-literal=PAYPAL_CLIENT_SECRET="$PAYPAL_SECRET" \
  --from-literal=AWS_ACCESS_KEY_ID="$AWS_KEY" \
  --from-literal=AWS_SECRET_ACCESS_KEY="$AWS_SECRET" \
  --from-literal=MAIL_USERNAME="$MAIL_USER" \
  --from-literal=MAIL_PASSWORD="$MAIL_PASS" \
  --from-literal=VIRTFUSION_API_KEY="$VF_KEY" \
  --from-literal=PTERODACTYL_APP_KEY="$PTERO_KEY" \
  --from-file=oauth-private.key="$TMP_KEYS/oauth-private.key" \
  --from-file=oauth-public.key="$TMP_KEYS/oauth-public.key"

# 5. Wipe the local key copies
rm -rf /tmp/_key_gen "$TMP_KEYS"

After this, helm install and every subsequent helm upgrade reference the existing Secret without ever rewriting it.

Deploy

helm upgrade --install ezscale-website ./helm/ezscale-website \
  --namespace ezscale \
  -f helm/ezscale-website/values-us-prod.yaml \
  --set image.tag=v0.1.0

Local dev cluster

# 1. Create cluster with Longhorn (or local-path) and the operator
k3d cluster create ezscale-local
helm repo add mariadb-operator https://helm.mariadb.com/mariadb-operator
helm install mariadb-operator -n mariadb-operator --create-namespace mariadb-operator/mariadb-operator

# 2. Build the image into the cluster
docker build --target app -t ezscale-website:app-local .
docker build --target horizon -t ezscale-website:horizon-local .
docker build --target scheduler -t ezscale-website:scheduler-local .
k3d image import ezscale-website:app-local ezscale-website:horizon-local ezscale-website:scheduler-local -c ezscale-local

# 3. Edit values-local.yaml — replace APP_KEY with `php artisan key:generate --show` output

# 4. Install
helm upgrade --install ezscale-website ./helm/ezscale-website \
  --namespace ezscale --create-namespace \
  -f helm/ezscale-website/values-local.yaml \
  --set image.registry=docker.io \
  --set image.repository=library/ezscale-website \
  --set image.tag=local

# 5. Port-forward and visit http://localhost:8080
kubectl port-forward -n ezscale svc/ezscale-website 8080:80

Operations

# Tail Horizon logs
kubectl logs -n ezscale -l app.kubernetes.io/component=horizon -f

# Run an artisan command in a one-off pod
kubectl run -n ezscale --rm -it --image=git.ezscale.cloud/ezscale/website:app-v0.1.0 \
  --env-from='[{"configMapRef":{"name":"ezscale-website-env"}},{"secretRef":{"name":"ezscale-website-secrets"}}]' \
  artisan-shell -- bash

# Force a Horizon rolling restart
kubectl rollout restart deployment -n ezscale ezscale-website-horizon

What's NOT in this chart (intentionally)

  • Cloudflare Zero Trust for the admin panel — layered on later.
  • ExternalDNS records — DNS managed manually in Cloudflare Terraform.
  • Backup CronJob for the application database — covered by the existing mariadb instance's backup CronJob in infrastructure/kubernetes/ezscale/mysql/.
  • Backups for Storj uploads — Storj provides its own replication; per-bucket lifecycle policies live in the Storj console.

- [ ] **Step 2: Commit**

```bash
git add helm/ezscale-website/README.md
git commit -m "docs(helm): chart README + APP_KEY/Passport bootstrap procedure

Spells out the one-time secret generation that must NEVER be re-run.
Documents local k3d setup and operations runbooks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 18: Gitea Actions release workflow

Files:

  • Create: .gitea/workflows/release.yml

  • Step 1: Create .gitea/workflows/release.yml

name: Release

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: git.ezscale.cloud
  IMAGE_BASE: ezscale/website

jobs:
  build-release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Get version from tag
        id: version
        run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Gitea registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.CI_TOKEN }}

      - name: Build and push app image
        uses: docker/build-push-action@v5
        with:
          context: .
          target: app
          push: true
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-app
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-app,mode=max
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:app-${{ steps.version.outputs.VERSION }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:app-latest

      - name: Build and push horizon image
        uses: docker/build-push-action@v5
        with:
          context: .
          target: horizon
          push: true
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-horizon
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-horizon,mode=max
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:horizon-${{ steps.version.outputs.VERSION }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:horizon-latest

      - name: Build and push scheduler image
        uses: docker/build-push-action@v5
        with:
          context: .
          target: scheduler
          push: true
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-scheduler
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-scheduler,mode=max
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:scheduler-${{ steps.version.outputs.VERSION }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:scheduler-latest

  deploy-us:
    runs-on: ubuntu-latest
    needs: build-release
    steps:
      - uses: actions/checkout@v4

      - name: Get version from tag
        id: version
        run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

      - name: Install helm
        uses: azure/setup-helm@v4
        with:
          version: v3.15.0

      - name: Configure kubeconfig
        run: |
          mkdir -p ~/.kube
          echo "${{ secrets.KUBECONFIG_US_PROD }}" | base64 -d > ~/.kube/config
          chmod 600 ~/.kube/config

      - name: Deploy to us-prod
        run: |
          helm upgrade --install ezscale-website ./helm/ezscale-website \
            --namespace ezscale \
            -f helm/ezscale-website/values-us-prod.yaml \
            --set image.tag=${{ steps.version.outputs.VERSION }} \
            --wait \
            --timeout 10m

Note: Requires two repository secrets in Gitea:

  • CI_TOKEN — Gitea token with write:package scope (for image push)

  • KUBECONFIG_US_PROD — base64-encoded kubeconfig with permission to deploy into the ezscale namespace

  • Step 2: Validate YAML syntax

python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/release.yml'))"

Expected: no output (YAML is valid).

  • Step 3: Commit
git add .gitea/workflows/release.yml
git commit -m "feat(ci): Gitea Actions release workflow on v* tags

Builds and pushes three images per tag, then runs helm upgrade
against us-prod. Cache-from/cache-to layers reuse buildx cache
across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 19: Local end-to-end smoke test

Files: None created — verification only.

Context: Spin up a k3d cluster, install the operator, install the chart, verify pods become Ready and the app responds.

  • Step 1: Create k3d cluster
k3d cluster create ezscale-local --wait
kubectl cluster-info

Expected: cluster reachable.

  • Step 2: Install mariadb-operator
helm repo add mariadb-operator https://helm.mariadb.com/mariadb-operator
helm repo update
helm install mariadb-operator -n mariadb-operator --create-namespace mariadb-operator/mariadb-operator --wait

Expected: operator pods Ready.

  • Step 3: Build & import images
cd /home/andrew/local_projects/website
docker build --target app       -t ezscale-website:app-local       .
docker build --target horizon   -t ezscale-website:horizon-local   .
docker build --target scheduler -t ezscale-website:scheduler-local .
k3d image import ezscale-website:app-local ezscale-website:horizon-local ezscale-website:scheduler-local -c ezscale-local

Expected: three images imported.

  • Step 4: Generate APP_KEY and patch values-local.yaml
# Generate an APP_KEY without depending on website/vendor existing.
# Format matches `php artisan key:generate --show`.
APP_KEY="base64:$(openssl rand -base64 32)"
sed -i "s|base64:CHANGEME_GENERATE_VIA_PHP_ARTISAN_KEY_GENERATE_SHOW|$APP_KEY|" helm/ezscale-website/values-local.yaml

Don't commit the patched values file. Reset it after the test:

git diff helm/ezscale-website/values-local.yaml | head
  • Step 5: Install the chart
helm upgrade --install ezscale-website ./helm/ezscale-website \
  --namespace ezscale --create-namespace \
  -f helm/ezscale-website/values-local.yaml \
  --set image.registry=docker.io \
  --set image.repository=library/ezscale-website \
  --set image.tag=local \
  --wait --timeout 10m

Expected: install completes, no failed Job, pods rolling.

  • Step 6: Verify pods are Ready
kubectl get pods -n ezscale

Expected: app, horizon, scheduler, mariadb (1 replica), valkey-0 — all Running and 1/1 Ready (app is 2/2 Ready). The migration Job should be Completed.

  • Step 7: Smoke-test the app
kubectl port-forward -n ezscale svc/ezscale-website 8080:80 &
PF_PID=$!
sleep 3
curl -fsS http://localhost:8080/up
echo
curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:8080/
kill $PF_PID

Expected: /up returns 200 (Laravel health endpoint), / returns 200 or 302 (redirect to login).

  • Step 8: Reset values-local.yaml and tear down
git checkout -- helm/ezscale-website/values-local.yaml
helm uninstall ezscale-website -n ezscale
k3d cluster delete ezscale-local
  • Step 9: No commit

This task is verification-only. If anything broke, return to the failing task and fix.


Final review

After all tasks above are complete:

  • helm lint helm/ezscale-website — clean
  • helm template helm/ezscale-website -f helm/ezscale-website/values-local.yaml — renders without error
  • helm template helm/ezscale-website -f helm/ezscale-website/values-us-prod.yaml --set image.tag=v0.1.0 — renders without error
  • docker build --target app . — succeeds
  • Local k3d e2e (Task 19) — green
  • README is accurate (counts, status, repo URL)
  • No top-level outdated docs left

When all green: first production tag:

git tag v0.1.0
git push origin v0.1.0

The Gitea workflow builds + pushes + deploys. Watch via kubectl rollout status -n ezscale deploy/ezscale-website-app.