# 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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 ` 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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) " ``` --- ## 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`.