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>
70 KiB
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.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 currentcomposer run devflow — fix anything wrong. -
Step 2: Remove
ipv4-outreach-tickets.txt
git rm ipv4-outreach-tickets.txt
- Step 3: Spot-check
GETTING_STARTED.mdfor 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.mdPhase 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 insidewebsite/) - 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
mariadbinstance's backup CronJob ininfrastructure/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 withwrite:packagescope (for image push) -
KUBECONFIG_US_PROD— base64-encoded kubeconfig with permission to deploy into theezscalenamespace -
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— cleanhelm template helm/ezscale-website -f helm/ezscale-website/values-local.yaml— renders without errorhelm template helm/ezscale-website -f helm/ezscale-website/values-us-prod.yaml --set image.tag=v0.1.0— renders without errordocker 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.