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

2307 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../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**
```bash
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**
```bash
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**
```bash
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**
```bash
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.txt`**delete**, 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`**
```bash
git rm ipv4-outreach-tickets.txt
```
- [ ] **Step 3: Spot-check `GETTING_STARTED.md` for staleness**
```bash
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**
```bash
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)**
```dockerfile
# 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**
```bash
mkdir -p docker
```
Create `docker/prod-entrypoint.sh`:
```bash
#!/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**
```bash
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**
```bash
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**
```bash
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**
```bash
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`**
```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)**
```yaml
# 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`**
```yaml
{{/* 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**
```bash
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**
```bash
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`**
```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`**
```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**
```bash
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**
```bash
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`**
```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**
```bash
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**
```bash
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`**
```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`**
```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**
```bash
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**
```bash
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`**
```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**
```bash
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**
```bash
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`**
```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**
```bash
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**
```bash
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`**
```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**
```bash
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**
```bash
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`**
```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**
```bash
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**
```bash
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`**
```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`**
```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`**
```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**
```bash
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**
```bash
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`**
```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**
```bash
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**
```bash
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`**
```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**
```bash
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**
```bash
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`**
```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`**
```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**
```bash
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**
```bash
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`**
```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`**
```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**
```bash
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**
```bash
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`**
```markdown
# 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
```bash
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
```bash
# 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
```bash
# 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`**
```yaml
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**
```bash
python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/release.yml'))"
```
Expected: no output (YAML is valid).
- [ ] **Step 3: Commit**
```bash
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**
```bash
k3d cluster create ezscale-local --wait
kubectl cluster-info
```
Expected: cluster reachable.
- [ ] **Step 2: Install mariadb-operator**
```bash
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**
```bash
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**
```bash
# 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:
```bash
git diff helm/ezscale-website/values-local.yaml | head
```
- [ ] **Step 5: Install the chart**
```bash
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**
```bash
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**
```bash
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**
```bash
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**:
```bash
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`.