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>
2307 lines
70 KiB
Markdown
2307 lines
70 KiB
Markdown
# EZSCALE Website K8s Deployment Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ship a production-ready multi-stage Docker image and a Helm chart (`ezscale-website`) that deploys the EZSCALE Laravel app to the existing K3s cluster, plus reconcile docs against reality.
|
||
|
||
**Architecture:** Three Dockerfile targets (`app`, `horizon`, `scheduler`) sharing a runtime base. Helm chart with three Deployments, optional in-cluster Valkey + MariaDB (toggleable per env), operator-managed `Database`/`User`/`Grant` CRDs against the existing `mariadb` instance in prod, Traefik IngressRoute + cert-manager TLS, Helm-hook migration Job. Two environments: `local` (k3d/minikube), `us-prod` (cluster `ezs-us-east-prod-01`, namespace `ezscale`).
|
||
|
||
**Tech Stack:** PHP 8.3-FPM, nginx 1.30-alpine, Composer 2, Node 24, Helm 3, mariadb-operator (`k8s.mariadb.com/v1alpha1`), Valkey 9, Traefik 3 IngressRoute, cert-manager, Gitea Container Registry, Gitea Actions.
|
||
|
||
**Spec:** [`docs/superpowers/specs/2026-04-26-k8s-deployment-design.md`](../specs/2026-04-26-k8s-deployment-design.md)
|
||
|
||
---
|
||
|
||
## Task 1: Reconcile README.md against reality
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
|
||
**Context:** README claims 59 migrations / 29 models / 85 pages / 43 tests. Reality is 94 / 53 / 165 / 51. Lists multi-currency, KB/FAQ, bandwidth as "Not Yet Implemented" — they exist (`currencies`, `knowledge_base_*`, `bandwidth_usage` migrations). Repo URL says GitHub `EZSCALE/accounting`; real remote is Gitea `EZSCALE/website`. "Last Updated: March 16, 2026" is stale.
|
||
|
||
- [ ] **Step 1: Pull live counts**
|
||
|
||
```bash
|
||
cd website
|
||
ls database/migrations | wc -l
|
||
find app/Models -maxdepth 1 -name '*.php' | wc -l
|
||
find resources/ts/Pages -name '*.vue' | wc -l
|
||
find tests -name '*Test.php' -o -name '*.spec.php' | wc -l
|
||
git -C .. log -1 --format=%cs # last-commit date
|
||
git -C .. remote get-url origin # real remote
|
||
```
|
||
|
||
Record outputs. Use them in step 2.
|
||
|
||
- [ ] **Step 2: Edit README.md**
|
||
|
||
In the "Repository" section, replace the GitHub URL with `git@git.ezscale.cloud:EZSCALE/website.git`. Note Gitea, not GitHub. Issues at `https://git.ezscale.cloud/EZSCALE/website/issues`.
|
||
|
||
In "Codebase at a Glance" table, update counts to live values (migrations, models, pages, tests). Recompute test count via `find tests -name '*Test.php' | wc -l`. Drop the "Assertions" row — it's stale and not worth keeping in sync.
|
||
|
||
In "Current Status → Implemented Features", add bullets for: multi-currency support, knowledge base + categories, blog (verify by `ls website/database/migrations | grep -i blog`).
|
||
|
||
In "Current Status → Not Yet Implemented", remove multi-currency, KB/FAQ, blog (whichever are now done). Keep CI/CD pipeline, staging environment, Cloudflare Zero Trust if still pending — verify each by spot-check.
|
||
|
||
In the bottom footer, change "Last Updated: March 16, 2026" to today's date and update the line summary if phase status has shifted.
|
||
|
||
- [ ] **Step 3: Verify README markdown renders**
|
||
|
||
```bash
|
||
cd /home/andrew/local_projects/website
|
||
grep -nE 'github\.com|59 |29 |85 |43 ' README.md
|
||
```
|
||
|
||
Expected: no matches (all stale references gone).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs(readme): reconcile against current reality
|
||
|
||
Update codebase counts to live values, fix Gitea repo URL (was GitHub),
|
||
move multi-currency/KB/blog from 'not yet implemented' to 'implemented',
|
||
refresh footer date.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Audit and clean outdated top-level files
|
||
|
||
**Files:**
|
||
- Inspect: `IDEAS.md`, `TASKS.md`, `PROJECT_DEVELOPMENT.md`, `FEATURES.md`, `ADVANCED_FEATURES.md`, `KASM_AND_MULTITENANCY.md`, `GETTING_STARTED.md`, `ipv4-outreach-tickets.txt`
|
||
- Likely delete: `ipv4-outreach-tickets.txt`
|
||
- Likely modify: `GETTING_STARTED.md` (verify still accurate), `TASKS.md` (mark phase status truthfully)
|
||
|
||
**Context:** Goal is "remove what's stale, keep what's load-bearing". Don't delete spec docs that future planning still references. The `ipv4-outreach-tickets.txt` is loose vendor outreach notes that has no business in the repo.
|
||
|
||
- [ ] **Step 1: Inspect each file's current relevance**
|
||
|
||
```bash
|
||
cd /home/andrew/local_projects/website
|
||
for f in IDEAS.md TASKS.md PROJECT_DEVELOPMENT.md FEATURES.md ADVANCED_FEATURES.md KASM_AND_MULTITENANCY.md GETTING_STARTED.md; do
|
||
echo "=== $f ==="
|
||
head -5 "$f"
|
||
echo "(last commit:)"
|
||
git log -1 --format='%cs %s' -- "$f"
|
||
done
|
||
file ipv4-outreach-tickets.txt
|
||
head -10 ipv4-outreach-tickets.txt
|
||
```
|
||
|
||
Decision rules:
|
||
- `ipv4-outreach-tickets.txt` → **delete**, it's operational outreach notes, not project docs.
|
||
- `IDEAS.md` → keep (long-term ideation, referenced by CLAUDE.md indirectly).
|
||
- `TASKS.md` → keep but verify accuracy of phase statuses.
|
||
- `PROJECT_DEVELOPMENT.md` → keep (architecture decisions doc, referenced by CLAUDE.md).
|
||
- `FEATURES.md`, `ADVANCED_FEATURES.md` → keep (feature specs).
|
||
- `KASM_AND_MULTITENANCY.md` → keep (referenced by CLAUDE.md).
|
||
- `GETTING_STARTED.md` → keep, but verify against current `composer run dev` flow — fix anything wrong.
|
||
|
||
- [ ] **Step 2: Remove `ipv4-outreach-tickets.txt`**
|
||
|
||
```bash
|
||
git rm ipv4-outreach-tickets.txt
|
||
```
|
||
|
||
- [ ] **Step 3: Spot-check `GETTING_STARTED.md` for staleness**
|
||
|
||
```bash
|
||
grep -nE 'php artisan|npm|composer|node|mysql' GETTING_STARTED.md | head -20
|
||
```
|
||
|
||
Compare against actual `website/composer.json` scripts and `website/package.json`. If the doc references commands that don't exist, edit it. Common breakage: references to `npm run dev` when `composer run dev` is now the canonical one-shot.
|
||
|
||
- [ ] **Step 4: Spot-check `TASKS.md` Phase 10 status**
|
||
|
||
`TASKS.md` Phase 10 should reflect that migrations / Cloudflare Zero Trust / staging / CI are still pending, since README still lists them. If `TASKS.md` already shows them as in-progress with sub-bullets, leave it. If it claims them complete but README says pending, reconcile by updating one to match the other (TASKS.md is generally the source of truth for phase status — update README to match TASKS.md, not the reverse).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "docs: remove ipv4-outreach-tickets.txt; refresh GETTING_STARTED
|
||
|
||
Outreach notes don't belong in the repo. GETTING_STARTED reconciled
|
||
against current composer/npm scripts.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Production multi-stage Dockerfile
|
||
|
||
**Files:**
|
||
- Create: `Dockerfile` (at repo root, NOT inside `website/`)
|
||
- Create: `.dockerignore` (at repo root)
|
||
|
||
**Context:** The repo has `website/` as the Laravel app, plus chart/docs/specs at the root. The build context for the production image is `./website`, so the Dockerfile is also under `./website` is fine — but the Helm chart and CI all live at the repo root, and we want one image build per repo. Place the Dockerfile at the repo root and use `./website` as the build copy source.
|
||
|
||
- [ ] **Step 1: Create `.dockerignore`**
|
||
|
||
```
|
||
# .dockerignore
|
||
.git
|
||
.gitignore
|
||
.github
|
||
.gitea
|
||
.idea
|
||
.vscode
|
||
.claude
|
||
.superpowers
|
||
.playwright-mcp
|
||
docker
|
||
docker-compose*.yml
|
||
.env*
|
||
!website/.env.example
|
||
docs
|
||
helm
|
||
node_modules
|
||
website/node_modules
|
||
website/vendor
|
||
website/storage/logs/*
|
||
website/storage/framework/cache/data/*
|
||
website/storage/framework/sessions/*
|
||
website/storage/framework/views/*
|
||
website/.phpunit.cache
|
||
website/tests
|
||
*.md
|
||
Makefile
|
||
scripts/whmcs-migrate
|
||
ipv4-outreach-tickets.txt
|
||
```
|
||
|
||
- [ ] **Step 2: Create `Dockerfile` (repo root)**
|
||
|
||
```dockerfile
|
||
# syntax=docker/dockerfile:1.7
|
||
|
||
# ==============================================================================
|
||
# composer-deps — install PHP deps (no scripts, no dev)
|
||
# ==============================================================================
|
||
FROM composer:2 AS composer-deps
|
||
WORKDIR /app
|
||
COPY website/composer.json website/composer.lock ./
|
||
RUN composer install \
|
||
--no-dev \
|
||
--no-scripts \
|
||
--no-autoloader \
|
||
--prefer-dist \
|
||
--no-interaction \
|
||
--no-progress
|
||
COPY website/ ./
|
||
RUN composer dump-autoload --optimize --classmap-authoritative
|
||
|
||
# ==============================================================================
|
||
# node-build — compile assets (Vite)
|
||
# ==============================================================================
|
||
FROM node:24-alpine AS node-build
|
||
WORKDIR /app
|
||
COPY website/package.json website/package-lock.json* ./
|
||
RUN npm ci --no-audit --no-fund
|
||
COPY website/ ./
|
||
# vendor/ is needed because Vite reads Laravel's helpers via @vite()
|
||
COPY --from=composer-deps /app/vendor ./vendor
|
||
RUN npm run build
|
||
|
||
# ==============================================================================
|
||
# runtime-base — common PHP 8.3-FPM image with extensions
|
||
# ==============================================================================
|
||
FROM php:8.3-fpm-bookworm AS runtime-base
|
||
|
||
ENV COMPOSER_ALLOW_SUPERUSER=1 \
|
||
COMPOSER_NO_INTERACTION=1
|
||
|
||
RUN apt-get update \
|
||
&& apt-get install -y --no-install-recommends \
|
||
git unzip curl ca-certificates \
|
||
libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libwebp-dev \
|
||
libicu-dev libonig-dev libxml2-dev libcurl4-openssl-dev libssl-dev \
|
||
pkg-config \
|
||
&& rm -rf /var/lib/apt/lists/*
|
||
|
||
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||
&& docker-php-ext-install -j"$(nproc)" \
|
||
pdo_mysql intl bcmath gd zip pcntl posix exif sockets opcache
|
||
|
||
RUN pecl install redis \
|
||
&& docker-php-ext-enable redis
|
||
|
||
# Production PHP / opcache config
|
||
RUN { \
|
||
echo 'memory_limit=512M'; \
|
||
echo 'upload_max_filesize=50M'; \
|
||
echo 'post_max_size=50M'; \
|
||
echo 'max_execution_time=120'; \
|
||
echo 'expose_php=Off'; \
|
||
} > /usr/local/etc/php/conf.d/zz-app.ini
|
||
|
||
RUN { \
|
||
echo 'opcache.enable=1'; \
|
||
echo 'opcache.enable_cli=0'; \
|
||
echo 'opcache.validate_timestamps=0'; \
|
||
echo 'opcache.memory_consumption=256'; \
|
||
echo 'opcache.interned_strings_buffer=16'; \
|
||
echo 'opcache.max_accelerated_files=20000'; \
|
||
echo 'opcache.preload_user=www-data'; \
|
||
} > /usr/local/etc/php/conf.d/zz-opcache.ini
|
||
|
||
# php-fpm pool — listen on 0.0.0.0:9000 (sidecar nginx connects to localhost)
|
||
RUN { \
|
||
echo '[www]'; \
|
||
echo 'user = www-data'; \
|
||
echo 'group = www-data'; \
|
||
echo 'listen = 0.0.0.0:9000'; \
|
||
echo 'pm = dynamic'; \
|
||
echo 'pm.max_children = 20'; \
|
||
echo 'pm.start_servers = 4'; \
|
||
echo 'pm.min_spare_servers = 2'; \
|
||
echo 'pm.max_spare_servers = 6'; \
|
||
echo 'pm.max_requests = 500'; \
|
||
echo 'clear_env = no'; \
|
||
} > /usr/local/etc/php-fpm.d/zz-app.conf
|
||
|
||
WORKDIR /var/www/html
|
||
|
||
# Bring in source + autoloaded vendor + built assets
|
||
COPY --chown=www-data:www-data --from=composer-deps /app /var/www/html
|
||
COPY --chown=www-data:www-data --from=node-build /app/public/build /var/www/html/public/build
|
||
|
||
# Cache config & routes at build time. Skip if no APP_KEY available.
|
||
# These are best-effort — caches will rebuild at runtime if needed.
|
||
RUN php artisan config:clear || true \
|
||
&& php artisan route:clear || true \
|
||
&& php artisan view:clear || true
|
||
|
||
# Entrypoint
|
||
COPY docker/prod-entrypoint.sh /usr/local/bin/entrypoint.sh
|
||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||
|
||
USER www-data
|
||
|
||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||
|
||
# ==============================================================================
|
||
# app — php-fpm (paired with nginx sidecar in k8s)
|
||
# ==============================================================================
|
||
FROM runtime-base AS app
|
||
EXPOSE 9000
|
||
CMD ["php-fpm"]
|
||
|
||
# ==============================================================================
|
||
# horizon — queue worker
|
||
# ==============================================================================
|
||
FROM runtime-base AS horizon
|
||
STOPSIGNAL SIGTERM
|
||
CMD ["php", "artisan", "horizon"]
|
||
|
||
# ==============================================================================
|
||
# scheduler — schedule:work loop
|
||
# ==============================================================================
|
||
FROM runtime-base AS scheduler
|
||
CMD ["php", "artisan", "schedule:work", "--no-interaction"]
|
||
```
|
||
|
||
- [ ] **Step 3: Create production entrypoint**
|
||
|
||
```bash
|
||
mkdir -p docker
|
||
```
|
||
|
||
Create `docker/prod-entrypoint.sh`:
|
||
|
||
```bash
|
||
#!/bin/sh
|
||
set -e
|
||
|
||
cd /var/www/html
|
||
|
||
# Install Passport keys from Secret-mounted location, if present.
|
||
# Chart mounts the keys at /var/www/html/secrets/oauth-{public,private}.key.
|
||
if [ -f /var/www/html/secrets/oauth-private.key ] \
|
||
&& [ -f /var/www/html/secrets/oauth-public.key ]; then
|
||
cp /var/www/html/secrets/oauth-private.key storage/oauth-private.key
|
||
cp /var/www/html/secrets/oauth-public.key storage/oauth-public.key
|
||
chmod 600 storage/oauth-private.key storage/oauth-public.key
|
||
fi
|
||
|
||
# Refresh Laravel caches against current env. Safe to run on every boot —
|
||
# config/route/view caching is per-pod and idempotent.
|
||
php artisan config:cache
|
||
php artisan route:cache
|
||
php artisan view:cache
|
||
php artisan event:cache || true
|
||
|
||
exec "$@"
|
||
```
|
||
|
||
- [ ] **Step 4: Build all three targets locally**
|
||
|
||
```bash
|
||
cd /home/andrew/local_projects/website
|
||
|
||
docker build --target app -t ezscale-website:test-app .
|
||
docker build --target horizon -t ezscale-website:test-horizon .
|
||
docker build --target scheduler -t ezscale-website:test-scheduler .
|
||
```
|
||
|
||
Expected: each build succeeds, no errors. The first build is slow (~5min); subsequent builds reuse layers.
|
||
|
||
- [ ] **Step 5: Smoke-test images**
|
||
|
||
```bash
|
||
docker run --rm ezscale-website:test-app php -v
|
||
docker run --rm ezscale-website:test-horizon php artisan --version
|
||
docker run --rm ezscale-website:test-scheduler php -m | grep -E 'opcache|pdo_mysql|redis'
|
||
```
|
||
|
||
Expected: PHP 8.3.x version, Laravel 12.x version, all three modules listed.
|
||
|
||
- [ ] **Step 6: Verify built assets are present**
|
||
|
||
```bash
|
||
docker run --rm ezscale-website:test-app ls -la public/build/
|
||
docker run --rm ezscale-website:test-app ls -la public/build/assets/ | head
|
||
```
|
||
|
||
Expected: a `manifest.json` and an `assets/` directory with hashed JS/CSS files.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add Dockerfile .dockerignore docker/prod-entrypoint.sh
|
||
git commit -m "feat(docker): production multi-stage Dockerfile
|
||
|
||
Three named targets (app, horizon, scheduler) sharing a runtime-base
|
||
with PHP 8.3-FPM, opcache, redis, and pinned php-fpm pool config.
|
||
Composer + Node build stages are separate so vendor/ and public/build/
|
||
are baked into the runtime image.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Helm chart skeleton
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/Chart.yaml`
|
||
- Create: `helm/ezscale-website/values.yaml`
|
||
- Create: `helm/ezscale-website/templates/_helpers.tpl`
|
||
- Create: `helm/ezscale-website/.helmignore`
|
||
|
||
- [ ] **Step 1: Create `Chart.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: v2
|
||
name: ezscale-website
|
||
description: EZSCALE billing platform — Laravel app (web + horizon + scheduler)
|
||
type: application
|
||
version: 0.1.0
|
||
appVersion: "0.1.0"
|
||
maintainers:
|
||
- name: EZSCALE
|
||
email: dev@ezscale.cloud
|
||
```
|
||
|
||
- [ ] **Step 2: Create `values.yaml` (defaults — dev-friendly, prod overrides via separate file)**
|
||
|
||
```yaml
|
||
# Default values: lean toward "self-contained dev cluster" so `helm install`
|
||
# with no flags produces a working stack on a local k3d. Production values
|
||
# live in values-us-prod.yaml and disable the in-cluster MariaDB/Valkey when
|
||
# pointing at the existing ezscale-namespace MariaDB.
|
||
|
||
replicaCount: 1
|
||
|
||
image:
|
||
registry: git.ezscale.cloud
|
||
repository: ezscale/website
|
||
# The chart appends `-{role}-{tag}` to derive each role's image.
|
||
# Override `tag` per-release via --set image.tag=v0.1.0
|
||
tag: latest
|
||
pullPolicy: IfNotPresent
|
||
|
||
imagePullSecrets:
|
||
- name: gitea-registry
|
||
|
||
nameOverride: ""
|
||
fullnameOverride: ""
|
||
|
||
# --- App (php-fpm + nginx sidecar) ---
|
||
app:
|
||
replicaCount: 1
|
||
autoscaling:
|
||
enabled: false
|
||
minReplicas: 1
|
||
maxReplicas: 8
|
||
targetCPU: 70
|
||
resources:
|
||
requests:
|
||
cpu: 100m
|
||
memory: 256Mi
|
||
limits:
|
||
cpu: 1000m
|
||
memory: 1Gi
|
||
|
||
# --- Horizon ---
|
||
horizon:
|
||
replicaCount: 1
|
||
resources:
|
||
requests:
|
||
cpu: 100m
|
||
memory: 256Mi
|
||
|
||
# --- Scheduler ---
|
||
scheduler:
|
||
replicaCount: 1
|
||
resources:
|
||
requests:
|
||
cpu: 50m
|
||
memory: 128Mi
|
||
limits:
|
||
cpu: 200m
|
||
memory: 256Mi
|
||
|
||
# --- In-cluster MariaDB (mariadb-operator CRD) ---
|
||
mariadb:
|
||
enabled: true
|
||
# When enabled, deploys a MariaDB CR named `{release}-mariadb` in this
|
||
# release's namespace. When disabled, the chart still creates Database/User/
|
||
# Grant CRDs but they reference an externally-managed MariaDB CR via
|
||
# `mariadb.externalRef`.
|
||
externalRef:
|
||
name: "" # e.g. "mariadb"
|
||
namespace: "" # e.g. "ezscale"
|
||
image: mariadb:11.4
|
||
replicas: 1
|
||
storage:
|
||
size: 5Gi
|
||
storageClassName: local-path
|
||
rootPasswordSecret: "" # if empty, chart generates a random secret
|
||
database: ezscale_billing
|
||
username: ezscale_billing_app
|
||
|
||
# --- In-cluster Valkey (StatefulSet) ---
|
||
valkey:
|
||
enabled: true
|
||
image: valkey/valkey:9-alpine
|
||
password: "" # if empty, chart generates a random secret
|
||
maxmemory: "1gb"
|
||
storage:
|
||
size: 5Gi
|
||
storageClassName: local-path
|
||
|
||
# --- Migration Job (Helm hook) ---
|
||
migrate:
|
||
enabled: true
|
||
seed: false
|
||
seedClass: ProductionSeeder
|
||
|
||
# --- Ingress (Traefik IngressRoute) ---
|
||
ingressRoute:
|
||
enabled: false
|
||
hosts:
|
||
- ezscale.cloud
|
||
- account.ezscale.cloud
|
||
- admin.ezscale.cloud
|
||
tls:
|
||
secretName: ezscale-website-tls
|
||
issuerName: letsencrypt
|
||
middlewares:
|
||
cloudflarewarp:
|
||
enabled: false
|
||
namespace: kube-system
|
||
name: cloudflarewarp
|
||
httpToHttps:
|
||
enabled: false
|
||
namespace: kube-system
|
||
name: http-to-https
|
||
|
||
# --- Service ---
|
||
service:
|
||
type: ClusterIP
|
||
port: 80
|
||
|
||
# --- Non-secret env vars (rendered into ConfigMap) ---
|
||
env:
|
||
APP_NAME: "EZSCALE Billing"
|
||
APP_ENV: production
|
||
APP_DEBUG: "false"
|
||
APP_URL: https://ezscale.cloud
|
||
APP_MAINTENANCE_DRIVER: file
|
||
LOG_CHANNEL: stack
|
||
LOG_STACK: single
|
||
LOG_LEVEL: info
|
||
DB_CONNECTION: mysql
|
||
DB_PORT: "3306"
|
||
DB_DATABASE: ezscale_billing
|
||
DB_USERNAME: ezscale_billing_app
|
||
REDIS_CLIENT: phpredis
|
||
REDIS_PORT: "6379"
|
||
SESSION_DRIVER: redis
|
||
SESSION_LIFETIME: "120"
|
||
SESSION_DOMAIN: .ezscale.cloud
|
||
CACHE_STORE: redis
|
||
QUEUE_CONNECTION: redis
|
||
BROADCAST_CONNECTION: log
|
||
FILESYSTEM_DISK: s3
|
||
MAIL_MAILER: smtp
|
||
AWS_DEFAULT_REGION: us-east-1
|
||
AWS_USE_PATH_STYLE_ENDPOINT: "true"
|
||
|
||
# --- Secret references (chart does NOT generate APP_KEY or Passport keys) ---
|
||
secret:
|
||
# When false, chart assumes a Secret named `secret.existingSecretName` is
|
||
# already present. This is the production path.
|
||
create: false
|
||
existingSecretName: ezscale-website-secrets
|
||
# Used only when create=true (local dev convenience).
|
||
values: {}
|
||
|
||
# --- Probes ---
|
||
healthCheck:
|
||
livenessPath: /up
|
||
readinessPath: /up
|
||
initialDelaySeconds: 15
|
||
periodSeconds: 15
|
||
timeoutSeconds: 5
|
||
failureThreshold: 3
|
||
```
|
||
|
||
- [ ] **Step 3: Create `templates/_helpers.tpl`**
|
||
|
||
```yaml
|
||
{{/* Common name helpers */}}
|
||
{{- define "ezscale-website.name" -}}
|
||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||
{{- end -}}
|
||
|
||
{{- define "ezscale-website.fullname" -}}
|
||
{{- if .Values.fullnameOverride -}}
|
||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||
{{- else -}}
|
||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||
{{- if contains $name .Release.Name -}}
|
||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||
{{- else -}}
|
||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||
{{- end -}}
|
||
{{- end -}}
|
||
{{- end -}}
|
||
|
||
{{- define "ezscale-website.labels" -}}
|
||
app.kubernetes.io/name: {{ include "ezscale-website.name" . }}
|
||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
||
{{- end -}}
|
||
|
||
{{- define "ezscale-website.selectorLabels" -}}
|
||
app.kubernetes.io/name: {{ include "ezscale-website.name" . }}
|
||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||
{{- end -}}
|
||
|
||
{{/*
|
||
Image reference for a given role (app/horizon/scheduler).
|
||
Usage: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }}
|
||
*/}}
|
||
{{- define "ezscale-website.image" -}}
|
||
{{- $ctx := .ctx -}}
|
||
{{- printf "%s/%s:%s-%s" $ctx.Values.image.registry $ctx.Values.image.repository .role $ctx.Values.image.tag -}}
|
||
{{- end -}}
|
||
|
||
{{/* Secret name (existing or generated) */}}
|
||
{{- define "ezscale-website.secretName" -}}
|
||
{{- if .Values.secret.create -}}
|
||
{{- include "ezscale-website.fullname" . -}}-secrets
|
||
{{- else -}}
|
||
{{- .Values.secret.existingSecretName -}}
|
||
{{- end -}}
|
||
{{- end -}}
|
||
|
||
{{/* DB host — points at in-cluster MariaDB or external one */}}
|
||
{{- define "ezscale-website.dbHost" -}}
|
||
{{- if .Values.mariadb.enabled -}}
|
||
{{ include "ezscale-website.fullname" . }}-mariadb
|
||
{{- else -}}
|
||
{{- $ref := .Values.mariadb.externalRef -}}
|
||
{{- printf "%s.%s.svc.cluster.local" $ref.name $ref.namespace -}}
|
||
{{- end -}}
|
||
{{- end -}}
|
||
|
||
{{/* Redis host */}}
|
||
{{- define "ezscale-website.redisHost" -}}
|
||
{{ include "ezscale-website.fullname" . }}-valkey
|
||
{{- end -}}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `.helmignore`**
|
||
|
||
```
|
||
.DS_Store
|
||
.git/
|
||
.gitignore
|
||
*.tmproj
|
||
.vscode/
|
||
*.swp
|
||
*.tmp
|
||
*.bak
|
||
README.md.bak
|
||
```
|
||
|
||
- [ ] **Step 5: Lint the chart**
|
||
|
||
```bash
|
||
cd /home/andrew/local_projects/website
|
||
helm lint helm/ezscale-website
|
||
```
|
||
|
||
Expected: `1 chart(s) linted, 0 chart(s) failed`. Templates directory is empty so far — that's fine for lint.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/
|
||
git commit -m "feat(helm): chart skeleton (Chart.yaml, values, helpers)
|
||
|
||
Initial scaffold for the ezscale-website chart. Defaults assume
|
||
self-contained local dev (in-cluster MariaDB + Valkey). Production
|
||
overrides will live in values-us-prod.yaml.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: ConfigMap and Secret placeholder templates
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/configmap.yaml`
|
||
- Create: `helm/ezscale-website/templates/secret.yaml`
|
||
|
||
- [ ] **Step 1: Create `configmap.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: v1
|
||
kind: ConfigMap
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-env
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
data:
|
||
{{- range $key, $value := .Values.env }}
|
||
{{ $key }}: {{ $value | quote }}
|
||
{{- end }}
|
||
DB_HOST: {{ include "ezscale-website.dbHost" . | quote }}
|
||
REDIS_HOST: {{ include "ezscale-website.redisHost" . | quote }}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `secret.yaml`**
|
||
|
||
```yaml
|
||
{{- if .Values.secret.create -}}
|
||
apiVersion: v1
|
||
kind: Secret
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-secrets
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
type: Opaque
|
||
stringData:
|
||
{{- range $key, $value := .Values.secret.values }}
|
||
{{ $key }}: {{ $value | quote }}
|
||
{{- end }}
|
||
{{- end -}}
|
||
```
|
||
|
||
- [ ] **Step 3: Render & validate**
|
||
|
||
```bash
|
||
cd /home/andrew/local_projects/website
|
||
helm template ezscale-website helm/ezscale-website > /tmp/render.yaml
|
||
kubectl apply --dry-run=client -f /tmp/render.yaml
|
||
```
|
||
|
||
Expected: ConfigMap dry-run passes (`configmap/release-name-ezscale-website-env created (dry run)`). No Secret because `secret.create=false` by default.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/configmap.yaml helm/ezscale-website/templates/secret.yaml
|
||
git commit -m "feat(helm): ConfigMap + Secret templates
|
||
|
||
ConfigMap renders all non-secret env vars including dynamic DB_HOST
|
||
and REDIS_HOST. Secret template only renders when secret.create=true
|
||
(dev convenience); production references an existing Secret.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Service template
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/service.yaml`
|
||
|
||
- [ ] **Step 1: Create `service.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: v1
|
||
kind: Service
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
spec:
|
||
type: {{ .Values.service.type }}
|
||
ports:
|
||
- port: {{ .Values.service.port }}
|
||
targetPort: http
|
||
protocol: TCP
|
||
name: http
|
||
selector:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 4 }}
|
||
app.kubernetes.io/component: app
|
||
```
|
||
|
||
- [ ] **Step 2: Verify rendering**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website | grep -A 12 'kind: Service'
|
||
```
|
||
|
||
Expected: a Service block with `targetPort: http` and the right selector.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/service.yaml
|
||
git commit -m "feat(helm): Service template (ClusterIP, port 80 → http)
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: App Deployment (php-fpm + nginx sidecar)
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/deployment-app.yaml`
|
||
- Create: `helm/ezscale-website/templates/configmap-nginx.yaml`
|
||
|
||
**Context:** The pod runs two containers (`nginx` + `app`) sharing source via an `emptyDir` populated by an init container. nginx vhost is rendered into a ConfigMap separate from the env ConfigMap so it can be edited without restart-cycling secrets.
|
||
|
||
- [ ] **Step 1: Create `configmap-nginx.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: v1
|
||
kind: ConfigMap
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-nginx
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
data:
|
||
default.conf: |
|
||
server {
|
||
listen 80 default_server;
|
||
server_name _;
|
||
root /var/www/html/public;
|
||
index index.php index.html;
|
||
|
||
client_max_body_size 50M;
|
||
charset utf-8;
|
||
|
||
add_header X-Frame-Options "SAMEORIGIN";
|
||
add_header X-Content-Type-Options "nosniff";
|
||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||
|
||
location = /favicon.ico { access_log off; log_not_found off; }
|
||
location = /robots.txt { access_log off; log_not_found off; }
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.php?$query_string;
|
||
}
|
||
|
||
location ~ \.php$ {
|
||
try_files $uri =404;
|
||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||
fastcgi_pass 127.0.0.1:9000;
|
||
fastcgi_index index.php;
|
||
include fastcgi_params;
|
||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||
fastcgi_param HTTP_PROXY "";
|
||
fastcgi_param HTTPS $http_x_forwarded_proto;
|
||
fastcgi_buffers 16 16k;
|
||
fastcgi_buffer_size 32k;
|
||
fastcgi_read_timeout 300;
|
||
}
|
||
|
||
location ~ /\.(?!well-known).* {
|
||
deny all;
|
||
access_log off;
|
||
log_not_found off;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `deployment-app.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: apps/v1
|
||
kind: Deployment
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-app
|
||
labels:
|
||
{{- include "ezscale-website.labels" . | nindent 4 }}
|
||
app.kubernetes.io/component: app
|
||
spec:
|
||
{{- if not .Values.app.autoscaling.enabled }}
|
||
replicas: {{ .Values.app.replicaCount }}
|
||
{{- end }}
|
||
selector:
|
||
matchLabels:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 6 }}
|
||
app.kubernetes.io/component: app
|
||
template:
|
||
metadata:
|
||
labels:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 8 }}
|
||
app.kubernetes.io/component: app
|
||
annotations:
|
||
# Restart pods when env or nginx config changes
|
||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||
checksum/nginx: {{ include (print $.Template.BasePath "/configmap-nginx.yaml") . | sha256sum }}
|
||
spec:
|
||
{{- with .Values.imagePullSecrets }}
|
||
imagePullSecrets:
|
||
{{- toYaml . | nindent 8 }}
|
||
{{- end }}
|
||
initContainers:
|
||
- name: copy-source
|
||
image: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }}
|
||
command:
|
||
- sh
|
||
- -c
|
||
- |
|
||
cp -a /var/www/html/. /shared/
|
||
volumeMounts:
|
||
- name: shared
|
||
mountPath: /shared
|
||
containers:
|
||
- name: nginx
|
||
image: nginx:1.30-alpine
|
||
ports:
|
||
- name: http
|
||
containerPort: 80
|
||
volumeMounts:
|
||
- name: shared
|
||
mountPath: /var/www/html
|
||
readOnly: true
|
||
- name: nginx-config
|
||
mountPath: /etc/nginx/conf.d
|
||
readOnly: true
|
||
livenessProbe:
|
||
httpGet:
|
||
path: {{ .Values.healthCheck.livenessPath }}
|
||
port: http
|
||
initialDelaySeconds: {{ .Values.healthCheck.initialDelaySeconds }}
|
||
periodSeconds: {{ .Values.healthCheck.periodSeconds }}
|
||
timeoutSeconds: {{ .Values.healthCheck.timeoutSeconds }}
|
||
failureThreshold: {{ .Values.healthCheck.failureThreshold }}
|
||
readinessProbe:
|
||
httpGet:
|
||
path: {{ .Values.healthCheck.readinessPath }}
|
||
port: http
|
||
initialDelaySeconds: 5
|
||
periodSeconds: 5
|
||
- name: app
|
||
image: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }}
|
||
ports:
|
||
- name: php-fpm
|
||
containerPort: 9000
|
||
envFrom:
|
||
- configMapRef:
|
||
name: {{ include "ezscale-website.fullname" . }}-env
|
||
- secretRef:
|
||
name: {{ include "ezscale-website.secretName" . }}
|
||
volumeMounts:
|
||
- name: shared
|
||
mountPath: /var/www/html
|
||
- name: oauth-keys
|
||
mountPath: /var/www/html/secrets
|
||
readOnly: true
|
||
resources:
|
||
{{- toYaml .Values.app.resources | nindent 12 }}
|
||
volumes:
|
||
- name: shared
|
||
emptyDir: {}
|
||
- name: nginx-config
|
||
configMap:
|
||
name: {{ include "ezscale-website.fullname" . }}-nginx
|
||
- name: oauth-keys
|
||
secret:
|
||
secretName: {{ include "ezscale-website.secretName" . }}
|
||
items:
|
||
- key: oauth-private.key
|
||
path: oauth-private.key
|
||
- key: oauth-public.key
|
||
path: oauth-public.key
|
||
optional: true
|
||
```
|
||
|
||
- [ ] **Step 3: Render & validate**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website \
|
||
--set secret.existingSecretName=test-secret \
|
||
> /tmp/render.yaml
|
||
kubectl apply --dry-run=client -f /tmp/render.yaml
|
||
```
|
||
|
||
Expected: deployment dry-runs cleanly. Confirm two containers + one init container + one Service in the rendered output.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/deployment-app.yaml helm/ezscale-website/templates/configmap-nginx.yaml
|
||
git commit -m "feat(helm): app Deployment (nginx + php-fpm sidecar)
|
||
|
||
Two-container pod sharing source via emptyDir populated by init
|
||
container. Nginx vhost in a separate ConfigMap. OAuth keys mounted
|
||
from the chart Secret as files under /var/www/html/secrets/, copied
|
||
into storage/ by the prod entrypoint.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: HPA for app
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/hpa-app.yaml`
|
||
|
||
- [ ] **Step 1: Create `hpa-app.yaml`**
|
||
|
||
```yaml
|
||
{{- if .Values.app.autoscaling.enabled }}
|
||
apiVersion: autoscaling/v2
|
||
kind: HorizontalPodAutoscaler
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-app
|
||
labels:
|
||
{{- include "ezscale-website.labels" . | nindent 4 }}
|
||
app.kubernetes.io/component: app
|
||
spec:
|
||
scaleTargetRef:
|
||
apiVersion: apps/v1
|
||
kind: Deployment
|
||
name: {{ include "ezscale-website.fullname" . }}-app
|
||
minReplicas: {{ .Values.app.autoscaling.minReplicas }}
|
||
maxReplicas: {{ .Values.app.autoscaling.maxReplicas }}
|
||
metrics:
|
||
- type: Resource
|
||
resource:
|
||
name: cpu
|
||
target:
|
||
type: Utilization
|
||
averageUtilization: {{ .Values.app.autoscaling.targetCPU }}
|
||
{{- end }}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify HPA renders only when enabled**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website | grep -c 'kind: HorizontalPodAutoscaler'
|
||
helm template ezscale-website helm/ezscale-website --set app.autoscaling.enabled=true | grep -c 'kind: HorizontalPodAutoscaler'
|
||
```
|
||
|
||
Expected: `0` then `1`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/hpa-app.yaml
|
||
git commit -m "feat(helm): HPA for app deployment (toggleable, CPU-based)
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Horizon Deployment
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/deployment-horizon.yaml`
|
||
|
||
- [ ] **Step 1: Create `deployment-horizon.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: apps/v1
|
||
kind: Deployment
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-horizon
|
||
labels:
|
||
{{- include "ezscale-website.labels" . | nindent 4 }}
|
||
app.kubernetes.io/component: horizon
|
||
spec:
|
||
replicas: {{ .Values.horizon.replicaCount }}
|
||
# Horizon needs SIGTERM + drain time. Don't run two replicas during update.
|
||
strategy:
|
||
type: Recreate
|
||
selector:
|
||
matchLabels:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 6 }}
|
||
app.kubernetes.io/component: horizon
|
||
template:
|
||
metadata:
|
||
labels:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 8 }}
|
||
app.kubernetes.io/component: horizon
|
||
annotations:
|
||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||
spec:
|
||
{{- with .Values.imagePullSecrets }}
|
||
imagePullSecrets:
|
||
{{- toYaml . | nindent 8 }}
|
||
{{- end }}
|
||
terminationGracePeriodSeconds: 60
|
||
containers:
|
||
- name: horizon
|
||
image: {{ include "ezscale-website.image" (dict "ctx" . "role" "horizon") }}
|
||
envFrom:
|
||
- configMapRef:
|
||
name: {{ include "ezscale-website.fullname" . }}-env
|
||
- secretRef:
|
||
name: {{ include "ezscale-website.secretName" . }}
|
||
volumeMounts:
|
||
- name: oauth-keys
|
||
mountPath: /var/www/html/secrets
|
||
readOnly: true
|
||
resources:
|
||
{{- toYaml .Values.horizon.resources | nindent 12 }}
|
||
livenessProbe:
|
||
exec:
|
||
command:
|
||
- php
|
||
- artisan
|
||
- horizon:status
|
||
initialDelaySeconds: 30
|
||
periodSeconds: 30
|
||
timeoutSeconds: 10
|
||
volumes:
|
||
- name: oauth-keys
|
||
secret:
|
||
secretName: {{ include "ezscale-website.secretName" . }}
|
||
items:
|
||
- key: oauth-private.key
|
||
path: oauth-private.key
|
||
- key: oauth-public.key
|
||
path: oauth-public.key
|
||
optional: true
|
||
```
|
||
|
||
- [ ] **Step 2: Validate**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website \
|
||
--set secret.existingSecretName=test-secret \
|
||
> /tmp/render.yaml
|
||
kubectl apply --dry-run=client -f /tmp/render.yaml
|
||
```
|
||
|
||
Expected: clean dry-run.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/deployment-horizon.yaml
|
||
git commit -m "feat(helm): Horizon deployment (Recreate strategy, 60s grace)
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Scheduler Deployment
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/deployment-scheduler.yaml`
|
||
|
||
- [ ] **Step 1: Create `deployment-scheduler.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: apps/v1
|
||
kind: Deployment
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-scheduler
|
||
labels:
|
||
{{- include "ezscale-website.labels" . | nindent 4 }}
|
||
app.kubernetes.io/component: scheduler
|
||
spec:
|
||
# Single replica only — running two schedule:work instances doubles tasks.
|
||
replicas: 1
|
||
strategy:
|
||
type: Recreate
|
||
selector:
|
||
matchLabels:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 6 }}
|
||
app.kubernetes.io/component: scheduler
|
||
template:
|
||
metadata:
|
||
labels:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 8 }}
|
||
app.kubernetes.io/component: scheduler
|
||
annotations:
|
||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||
spec:
|
||
{{- with .Values.imagePullSecrets }}
|
||
imagePullSecrets:
|
||
{{- toYaml . | nindent 8 }}
|
||
{{- end }}
|
||
containers:
|
||
- name: scheduler
|
||
image: {{ include "ezscale-website.image" (dict "ctx" . "role" "scheduler") }}
|
||
envFrom:
|
||
- configMapRef:
|
||
name: {{ include "ezscale-website.fullname" . }}-env
|
||
- secretRef:
|
||
name: {{ include "ezscale-website.secretName" . }}
|
||
resources:
|
||
{{- toYaml .Values.scheduler.resources | nindent 12 }}
|
||
```
|
||
|
||
- [ ] **Step 2: Validate**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website \
|
||
--set secret.existingSecretName=test-secret | kubectl apply --dry-run=client -f -
|
||
```
|
||
|
||
Expected: clean dry-run.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/deployment-scheduler.yaml
|
||
git commit -m "feat(helm): scheduler deployment (single replica, schedule:work)
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Migration Job (Helm hook)
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/job-migrate.yaml`
|
||
|
||
- [ ] **Step 1: Create `job-migrate.yaml`**
|
||
|
||
```yaml
|
||
{{- if .Values.migrate.enabled }}
|
||
apiVersion: batch/v1
|
||
kind: Job
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-migrate
|
||
labels:
|
||
{{- include "ezscale-website.labels" . | nindent 4 }}
|
||
app.kubernetes.io/component: migrate
|
||
annotations:
|
||
"helm.sh/hook": pre-install,pre-upgrade
|
||
"helm.sh/hook-weight": "0"
|
||
"helm.sh/hook-delete-policy": before-hook-creation
|
||
spec:
|
||
backoffLimit: 1
|
||
ttlSecondsAfterFinished: 3600
|
||
template:
|
||
metadata:
|
||
labels:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 8 }}
|
||
app.kubernetes.io/component: migrate
|
||
spec:
|
||
restartPolicy: Never
|
||
{{- with .Values.imagePullSecrets }}
|
||
imagePullSecrets:
|
||
{{- toYaml . | nindent 8 }}
|
||
{{- end }}
|
||
containers:
|
||
- name: migrate
|
||
image: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }}
|
||
command:
|
||
- sh
|
||
- -c
|
||
- |
|
||
set -e
|
||
php artisan migrate --force --no-interaction
|
||
{{- if .Values.migrate.seed }}
|
||
php artisan db:seed --class={{ .Values.migrate.seedClass }} --force --no-interaction
|
||
{{- end }}
|
||
envFrom:
|
||
- configMapRef:
|
||
name: {{ include "ezscale-website.fullname" . }}-env
|
||
- secretRef:
|
||
name: {{ include "ezscale-website.secretName" . }}
|
||
{{- end }}
|
||
```
|
||
|
||
- [ ] **Step 2: Validate**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website \
|
||
--set secret.existingSecretName=test-secret | grep -A 5 'kind: Job'
|
||
```
|
||
|
||
Expected: a Job block with the `helm.sh/hook` annotations.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/job-migrate.yaml
|
||
git commit -m "feat(helm): pre-install/pre-upgrade migration Job
|
||
|
||
Helm hook runs migrate (and optionally seed) before any pod rolls.
|
||
If the Job fails, helm upgrade aborts and the previous ReplicaSet
|
||
keeps serving traffic.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: MariaDB CRDs (Database, User, Grant)
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/mariadb-database.yaml`
|
||
- Create: `helm/ezscale-website/templates/mariadb-user.yaml`
|
||
- Create: `helm/ezscale-website/templates/mariadb-grant.yaml`
|
||
|
||
**Context:** When `mariadb.enabled=true`, the CRDs reference the in-cluster MariaDB instance this chart deploys (Task 13). When `mariadb.enabled=false`, they reference an external one specified in `mariadb.externalRef`. Either way, the chart's app pods authenticate as the user this declares.
|
||
|
||
- [ ] **Step 1: Create `mariadb-database.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: k8s.mariadb.com/v1alpha1
|
||
kind: Database
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-db
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
spec:
|
||
mariaDbRef:
|
||
{{- if .Values.mariadb.enabled }}
|
||
name: {{ include "ezscale-website.fullname" . }}-mariadb
|
||
{{- else }}
|
||
name: {{ .Values.mariadb.externalRef.name }}
|
||
namespace: {{ .Values.mariadb.externalRef.namespace }}
|
||
{{- end }}
|
||
characterSet: utf8mb4
|
||
collate: utf8mb4_unicode_ci
|
||
name: {{ .Values.mariadb.database }}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `mariadb-user.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: k8s.mariadb.com/v1alpha1
|
||
kind: User
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-user
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
spec:
|
||
# spec.name overrides metadata.name as the SQL identifier — needed because
|
||
# k8s resource names can't contain underscores but our SQL username can.
|
||
name: {{ .Values.mariadb.username }}
|
||
mariaDbRef:
|
||
{{- if .Values.mariadb.enabled }}
|
||
name: {{ include "ezscale-website.fullname" . }}-mariadb
|
||
{{- else }}
|
||
name: {{ .Values.mariadb.externalRef.name }}
|
||
namespace: {{ .Values.mariadb.externalRef.namespace }}
|
||
{{- end }}
|
||
passwordSecretKeyRef:
|
||
name: {{ include "ezscale-website.secretName" . }}
|
||
key: DB_PASSWORD
|
||
host: "%"
|
||
maxUserConnections: 50
|
||
```
|
||
|
||
- [ ] **Step 3: Create `mariadb-grant.yaml`**
|
||
|
||
```yaml
|
||
apiVersion: k8s.mariadb.com/v1alpha1
|
||
kind: Grant
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-grant
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
spec:
|
||
mariaDbRef:
|
||
{{- if .Values.mariadb.enabled }}
|
||
name: {{ include "ezscale-website.fullname" . }}-mariadb
|
||
{{- else }}
|
||
name: {{ .Values.mariadb.externalRef.name }}
|
||
namespace: {{ .Values.mariadb.externalRef.namespace }}
|
||
{{- end }}
|
||
username: {{ .Values.mariadb.username }}
|
||
host: "%"
|
||
privileges:
|
||
- "ALL PRIVILEGES"
|
||
database: {{ .Values.mariadb.database }}
|
||
table: "*"
|
||
```
|
||
|
||
- [ ] **Step 4: Validate**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website | grep -E 'kind: (Database|User|Grant)'
|
||
```
|
||
|
||
Expected: three matches. Note `kubectl apply --dry-run=client` will fail on these since the CRDs aren't installed in the local kubectl context — that's expected. The template syntax check via `helm template` is enough here.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/mariadb-database.yaml \
|
||
helm/ezscale-website/templates/mariadb-user.yaml \
|
||
helm/ezscale-website/templates/mariadb-grant.yaml
|
||
git commit -m "feat(helm): mariadb-operator Database/User/Grant CRDs
|
||
|
||
When mariadb.enabled=true, references the in-cluster MariaDB this
|
||
chart deploys. When false, references an external CR via
|
||
mariadb.externalRef. Privileges scoped to the website's database
|
||
only — no global ALL PRIVILEGES.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: MariaDB instance template (toggleable)
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/mariadb-instance.yaml`
|
||
|
||
- [ ] **Step 1: Create `mariadb-instance.yaml`**
|
||
|
||
```yaml
|
||
{{- if .Values.mariadb.enabled }}
|
||
{{- $rootSecretName := default (printf "%s-mariadb-root" (include "ezscale-website.fullname" .)) .Values.mariadb.rootPasswordSecret }}
|
||
{{- if not .Values.mariadb.rootPasswordSecret }}
|
||
apiVersion: v1
|
||
kind: Secret
|
||
metadata:
|
||
name: {{ $rootSecretName }}
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
annotations:
|
||
helm.sh/resource-policy: keep
|
||
type: Opaque
|
||
stringData:
|
||
password: {{ randAlphaNum 32 | quote }}
|
||
---
|
||
{{- end }}
|
||
apiVersion: k8s.mariadb.com/v1alpha1
|
||
kind: MariaDB
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-mariadb
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
spec:
|
||
image: {{ .Values.mariadb.image }}
|
||
rootPasswordSecretKeyRef:
|
||
name: {{ $rootSecretName }}
|
||
key: password
|
||
generate: false
|
||
replicas: {{ .Values.mariadb.replicas }}
|
||
storage:
|
||
size: {{ .Values.mariadb.storage.size }}
|
||
storageClassName: {{ .Values.mariadb.storage.storageClassName }}
|
||
{{- end }}
|
||
```
|
||
|
||
**Note** about the `helm.sh/resource-policy: keep` annotation on the generated root password Secret: it prevents `helm uninstall` from deleting the root password, which would orphan the data PVC. For local dev clean slates use `kubectl delete secret <name>` manually.
|
||
|
||
- [ ] **Step 2: Validate**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website > /tmp/render.yaml
|
||
grep -E 'kind: MariaDB|generate: false' /tmp/render.yaml
|
||
```
|
||
|
||
Expected: one `kind: MariaDB` plus `generate: false`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/mariadb-instance.yaml
|
||
git commit -m "feat(helm): in-cluster MariaDB CR (toggleable for dev)
|
||
|
||
Renders only when mariadb.enabled=true. Generates a random root
|
||
password Secret with helm.sh/resource-policy=keep so uninstall
|
||
doesn't orphan the data volume.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Valkey StatefulSet template
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/statefulset-valkey.yaml`
|
||
|
||
- [ ] **Step 1: Create `statefulset-valkey.yaml`**
|
||
|
||
```yaml
|
||
{{- if .Values.valkey.enabled }}
|
||
{{- $secretName := printf "%s-valkey" (include "ezscale-website.fullname" .) }}
|
||
{{- if .Values.valkey.password }}
|
||
apiVersion: v1
|
||
kind: Secret
|
||
metadata:
|
||
name: {{ $secretName }}
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
annotations:
|
||
helm.sh/resource-policy: keep
|
||
type: Opaque
|
||
stringData:
|
||
password: {{ .Values.valkey.password | quote }}
|
||
---
|
||
{{- end }}
|
||
apiVersion: v1
|
||
kind: Service
|
||
metadata:
|
||
name: {{ include "ezscale-website.redisHost" . }}
|
||
labels:
|
||
{{- include "ezscale-website.labels" . | nindent 4 }}
|
||
app.kubernetes.io/component: valkey
|
||
spec:
|
||
type: ClusterIP
|
||
ports:
|
||
- port: 6379
|
||
targetPort: redis
|
||
name: redis
|
||
selector:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 4 }}
|
||
app.kubernetes.io/component: valkey
|
||
---
|
||
apiVersion: apps/v1
|
||
kind: StatefulSet
|
||
metadata:
|
||
name: {{ include "ezscale-website.redisHost" . }}
|
||
labels:
|
||
{{- include "ezscale-website.labels" . | nindent 4 }}
|
||
app.kubernetes.io/component: valkey
|
||
spec:
|
||
serviceName: {{ include "ezscale-website.redisHost" . }}
|
||
replicas: 1
|
||
selector:
|
||
matchLabels:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 6 }}
|
||
app.kubernetes.io/component: valkey
|
||
template:
|
||
metadata:
|
||
labels:
|
||
{{- include "ezscale-website.selectorLabels" . | nindent 8 }}
|
||
app.kubernetes.io/component: valkey
|
||
spec:
|
||
containers:
|
||
- name: valkey
|
||
image: {{ .Values.valkey.image }}
|
||
command:
|
||
- valkey-server
|
||
- --appendonly
|
||
- "yes"
|
||
- --maxmemory
|
||
- {{ .Values.valkey.maxmemory | quote }}
|
||
- --maxmemory-policy
|
||
- allkeys-lru
|
||
{{- if .Values.valkey.password }}
|
||
- --requirepass
|
||
- $(REDIS_PASSWORD)
|
||
{{- end }}
|
||
{{- if .Values.valkey.password }}
|
||
env:
|
||
- name: REDIS_PASSWORD
|
||
valueFrom:
|
||
secretKeyRef:
|
||
name: {{ $secretName }}
|
||
key: password
|
||
{{- end }}
|
||
ports:
|
||
- name: redis
|
||
containerPort: 6379
|
||
volumeMounts:
|
||
- name: data
|
||
mountPath: /data
|
||
livenessProbe:
|
||
exec:
|
||
command: ["valkey-cli", "ping"]
|
||
initialDelaySeconds: 10
|
||
periodSeconds: 10
|
||
volumeClaimTemplates:
|
||
- metadata:
|
||
name: data
|
||
spec:
|
||
accessModes: ["ReadWriteOnce"]
|
||
storageClassName: {{ .Values.valkey.storage.storageClassName }}
|
||
resources:
|
||
requests:
|
||
storage: {{ .Values.valkey.storage.size }}
|
||
{{- end }}
|
||
```
|
||
|
||
- [ ] **Step 2: Validate**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website > /tmp/render.yaml
|
||
grep -E 'kind: (Service|StatefulSet)' /tmp/render.yaml
|
||
```
|
||
|
||
Expected: at least one Service (the app) + one Service (valkey) + one StatefulSet.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/statefulset-valkey.yaml
|
||
git commit -m "feat(helm): in-cluster Valkey StatefulSet (toggleable)
|
||
|
||
AOF persistence + LRU eviction + optional password. PVC for the
|
||
queue data so Horizon doesn't lose pending jobs on pod restart.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: IngressRoute and Certificate templates
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/templates/ingressroute.yaml`
|
||
- Create: `helm/ezscale-website/templates/certificate.yaml`
|
||
|
||
- [ ] **Step 1: Create `ingressroute.yaml`**
|
||
|
||
```yaml
|
||
{{- if .Values.ingressRoute.enabled }}
|
||
{{- $hostMatch := "" -}}
|
||
{{- range $i, $h := .Values.ingressRoute.hosts -}}
|
||
{{- if eq $i 0 -}}
|
||
{{- $hostMatch = printf "Host(`%s`)" $h -}}
|
||
{{- else -}}
|
||
{{- $hostMatch = printf "%s || Host(`%s`)" $hostMatch $h -}}
|
||
{{- end -}}
|
||
{{- end }}
|
||
---
|
||
apiVersion: traefik.io/v1alpha1
|
||
kind: IngressRoute
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-https
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
spec:
|
||
entryPoints: [websecure]
|
||
routes:
|
||
- kind: Rule
|
||
match: {{ $hostMatch }}
|
||
middlewares:
|
||
{{- if .Values.ingressRoute.middlewares.cloudflarewarp.enabled }}
|
||
- name: {{ .Values.ingressRoute.middlewares.cloudflarewarp.name }}
|
||
namespace: {{ .Values.ingressRoute.middlewares.cloudflarewarp.namespace }}
|
||
{{- end }}
|
||
services:
|
||
- name: {{ include "ezscale-website.fullname" . }}
|
||
port: {{ .Values.service.port }}
|
||
tls:
|
||
secretName: {{ .Values.ingressRoute.tls.secretName }}
|
||
---
|
||
apiVersion: traefik.io/v1alpha1
|
||
kind: IngressRoute
|
||
metadata:
|
||
name: {{ include "ezscale-website.fullname" . }}-http
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
spec:
|
||
entryPoints: [web]
|
||
routes:
|
||
- kind: Rule
|
||
match: {{ $hostMatch }}
|
||
middlewares:
|
||
{{- if .Values.ingressRoute.middlewares.httpToHttps.enabled }}
|
||
- name: {{ .Values.ingressRoute.middlewares.httpToHttps.name }}
|
||
namespace: {{ .Values.ingressRoute.middlewares.httpToHttps.namespace }}
|
||
{{- end }}
|
||
services:
|
||
- name: {{ include "ezscale-website.fullname" . }}
|
||
port: {{ .Values.service.port }}
|
||
{{- end }}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `certificate.yaml`**
|
||
|
||
```yaml
|
||
{{- if .Values.ingressRoute.enabled }}
|
||
apiVersion: cert-manager.io/v1
|
||
kind: Certificate
|
||
metadata:
|
||
name: {{ .Values.ingressRoute.tls.secretName }}
|
||
labels: {{- include "ezscale-website.labels" . | nindent 4 }}
|
||
spec:
|
||
secretName: {{ .Values.ingressRoute.tls.secretName }}
|
||
issuerRef:
|
||
kind: ClusterIssuer
|
||
name: {{ .Values.ingressRoute.tls.issuerName }}
|
||
dnsNames:
|
||
{{- range .Values.ingressRoute.hosts }}
|
||
- {{ . | quote }}
|
||
{{- end }}
|
||
{{- end }}
|
||
```
|
||
|
||
- [ ] **Step 3: Validate**
|
||
|
||
```bash
|
||
helm template ezscale-website helm/ezscale-website \
|
||
--set ingressRoute.enabled=true \
|
||
--set ingressRoute.middlewares.cloudflarewarp.enabled=true \
|
||
--set ingressRoute.middlewares.httpToHttps.enabled=true \
|
||
| grep -E 'kind: (IngressRoute|Certificate)'
|
||
```
|
||
|
||
Expected: 2 IngressRoutes + 1 Certificate.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/templates/ingressroute.yaml \
|
||
helm/ezscale-website/templates/certificate.yaml
|
||
git commit -m "feat(helm): Traefik IngressRoute + cert-manager Certificate
|
||
|
||
Two IngressRoutes (web → http-to-https redirect, websecure → app)
|
||
covering all configured hosts. Certificate covers all hosts as SANs.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Environment values files
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/values-local.yaml`
|
||
- Create: `helm/ezscale-website/values-us-prod.yaml`
|
||
|
||
- [ ] **Step 1: Create `values-local.yaml`**
|
||
|
||
```yaml
|
||
# ===========================================================================
|
||
# Local k3d/minikube cluster — fully self-contained.
|
||
# Prerequisite: mariadb-operator installed in the cluster.
|
||
# helm install mariadb-operator -n mariadb-operator --create-namespace \
|
||
# mariadb-operator/mariadb-operator
|
||
# ===========================================================================
|
||
|
||
image:
|
||
tag: latest
|
||
pullPolicy: Always
|
||
|
||
imagePullSecrets: [] # no registry auth needed for locally-built images
|
||
|
||
app:
|
||
replicaCount: 1
|
||
resources:
|
||
requests: { cpu: 100m, memory: 256Mi }
|
||
|
||
mariadb:
|
||
enabled: true
|
||
replicas: 1
|
||
storage:
|
||
size: 5Gi
|
||
storageClassName: local-path
|
||
|
||
valkey:
|
||
enabled: true
|
||
storage:
|
||
size: 1Gi
|
||
storageClassName: local-path
|
||
|
||
migrate:
|
||
enabled: true
|
||
seed: true
|
||
seedClass: DemoDataSeeder
|
||
|
||
ingressRoute:
|
||
enabled: false # local uses port-forward, not Traefik
|
||
|
||
# Local dev: chart generates a random APP_KEY on first install.
|
||
# This is OK in local because there's no encrypted prod data to lose.
|
||
# In production this MUST be `secret.create=false`.
|
||
secret:
|
||
create: true
|
||
existingSecretName: ""
|
||
values:
|
||
APP_KEY: "base64:CHANGEME_GENERATE_VIA_PHP_ARTISAN_KEY_GENERATE_SHOW"
|
||
DB_PASSWORD: "local_dev_password"
|
||
AWS_ACCESS_KEY_ID: ""
|
||
AWS_SECRET_ACCESS_KEY: ""
|
||
STRIPE_KEY: ""
|
||
STRIPE_SECRET: ""
|
||
|
||
env:
|
||
APP_ENV: local
|
||
APP_DEBUG: "true"
|
||
APP_URL: http://localhost
|
||
LOG_LEVEL: debug
|
||
FILESYSTEM_DISK: local
|
||
MAIL_MAILER: log
|
||
SESSION_DOMAIN: ""
|
||
```
|
||
|
||
**Note:** `APP_KEY` placeholder must be replaced before first install. Run `php artisan key:generate --show` against any working Laravel checkout, paste the result here.
|
||
|
||
- [ ] **Step 2: Create `values-us-prod.yaml`**
|
||
|
||
```yaml
|
||
# ===========================================================================
|
||
# Production: ezs-us-east-prod-01.node.ezscale.tech
|
||
# Namespace: ezscale (shared with mariadb instance + ezscale_api)
|
||
# ===========================================================================
|
||
|
||
image:
|
||
registry: git.ezscale.cloud
|
||
repository: ezscale/website
|
||
tag: "" # SET via --set image.tag=v0.1.0 at deploy time
|
||
pullPolicy: IfNotPresent
|
||
|
||
imagePullSecrets:
|
||
- name: gitea-registry
|
||
|
||
app:
|
||
replicaCount: 2
|
||
autoscaling:
|
||
enabled: true
|
||
minReplicas: 2
|
||
maxReplicas: 8
|
||
targetCPU: 70
|
||
resources:
|
||
requests: { cpu: 200m, memory: 512Mi }
|
||
limits: { cpu: 1500m, memory: 1536Mi }
|
||
|
||
horizon:
|
||
replicaCount: 1
|
||
resources:
|
||
requests: { cpu: 200m, memory: 512Mi }
|
||
limits: { cpu: 1000m, memory: 1Gi }
|
||
|
||
scheduler:
|
||
replicaCount: 1
|
||
resources:
|
||
requests: { cpu: 50m, memory: 128Mi }
|
||
|
||
# Reuse the cluster's existing replicated MariaDB.
|
||
mariadb:
|
||
enabled: false
|
||
externalRef:
|
||
name: mariadb
|
||
namespace: ezscale
|
||
database: ezscale_billing
|
||
username: ezscale_billing_app
|
||
|
||
# Per-app Valkey for sessions/cache/queue.
|
||
valkey:
|
||
enabled: true
|
||
storage:
|
||
size: 10Gi
|
||
storageClassName: longhorn
|
||
|
||
migrate:
|
||
enabled: true
|
||
seed: false
|
||
|
||
ingressRoute:
|
||
enabled: true
|
||
hosts:
|
||
- ezscale.cloud
|
||
- account.ezscale.cloud
|
||
- admin.ezscale.cloud
|
||
tls:
|
||
secretName: ezscale-website-tls
|
||
issuerName: letsencrypt
|
||
middlewares:
|
||
cloudflarewarp:
|
||
enabled: true
|
||
namespace: kube-system
|
||
name: cloudflarewarp
|
||
httpToHttps:
|
||
enabled: true
|
||
namespace: kube-system
|
||
name: http-to-https
|
||
|
||
# Production NEVER lets the chart generate APP_KEY. Bootstrap procedure
|
||
# in helm/ezscale-website/README.md.
|
||
secret:
|
||
create: false
|
||
existingSecretName: ezscale-website-secrets
|
||
|
||
env:
|
||
APP_NAME: "EZSCALE Billing"
|
||
APP_ENV: production
|
||
APP_DEBUG: "false"
|
||
APP_URL: https://ezscale.cloud
|
||
LOG_LEVEL: warning
|
||
FILESYSTEM_DISK: s3
|
||
AWS_BUCKET: ezscale-website-prod
|
||
AWS_DEFAULT_REGION: us-east-1
|
||
AWS_ENDPOINT: https://gateway.storjshare.io
|
||
AWS_USE_PATH_STYLE_ENDPOINT: "true"
|
||
SESSION_DRIVER: redis
|
||
SESSION_DOMAIN: .ezscale.cloud
|
||
CACHE_STORE: redis
|
||
QUEUE_CONNECTION: redis
|
||
MAIL_MAILER: smtp
|
||
TRUSTED_PROXIES: "*"
|
||
```
|
||
|
||
- [ ] **Step 3: Render both and validate template-only**
|
||
|
||
```bash
|
||
cd /home/andrew/local_projects/website
|
||
helm template ezscale-website helm/ezscale-website -f helm/ezscale-website/values-local.yaml > /tmp/local.yaml
|
||
helm template ezscale-website helm/ezscale-website -f helm/ezscale-website/values-us-prod.yaml --set image.tag=v0.1.0 > /tmp/prod.yaml
|
||
echo "Local: $(wc -l < /tmp/local.yaml) lines"
|
||
echo "Prod: $(wc -l < /tmp/prod.yaml) lines"
|
||
grep -E '^kind: ' /tmp/prod.yaml | sort | uniq -c
|
||
```
|
||
|
||
Expected: prod render shows ConfigMap, Database, Deployment×3, Grant, IngressRoute×2, Job, Service×2, StatefulSet, User, Certificate, HorizontalPodAutoscaler. Not MariaDB (because external) and not the local-only generated Secret.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/values-local.yaml helm/ezscale-website/values-us-prod.yaml
|
||
git commit -m "feat(helm): values-local + values-us-prod
|
||
|
||
Local: in-cluster MariaDB + Valkey, port-forward instead of ingress,
|
||
chart-generated APP_KEY (dev only).
|
||
Prod: external MariaDB (ezscale ns), Longhorn-backed Valkey, Traefik
|
||
IngressRoute with cloudflarewarp + cert-manager TLS, image.tag set
|
||
at deploy time, secret pre-created out-of-band.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Helm chart README + bootstrap procedure
|
||
|
||
**Files:**
|
||
- Create: `helm/ezscale-website/README.md`
|
||
|
||
- [ ] **Step 1: Create `helm/ezscale-website/README.md`**
|
||
|
||
```markdown
|
||
# ezscale-website Helm Chart
|
||
|
||
Production deployment for the EZSCALE billing platform Laravel app.
|
||
|
||
## Environments
|
||
|
||
| Env | Values file | Notes |
|
||
|---|---|---|
|
||
| Local k3d | `values-local.yaml` | Self-contained: in-cluster MariaDB + Valkey. No ingress (use port-forward). |
|
||
| US prod | `values-us-prod.yaml` | Connects to existing `mariadb` CR in `ezscale` ns; per-app Valkey on Longhorn. |
|
||
|
||
## Prerequisites (us-prod)
|
||
|
||
The `ezscale` namespace must already have:
|
||
- mariadb-operator installed cluster-wide (provides `MariaDB`/`Database`/`User`/`Grant` CRDs)
|
||
- A `MariaDB` CR named `mariadb`
|
||
- cert-manager `ClusterIssuer` named `letsencrypt`
|
||
- Traefik with `cloudflarewarp` and `http-to-https` middlewares in `kube-system`
|
||
- Image-pull `Secret` named `gitea-registry`
|
||
|
||
These are all already provisioned per `infrastructure/kubernetes/`.
|
||
|
||
## First-time bootstrap (us-prod)
|
||
|
||
This step is run **once** per environment, never repeated. The Secret it creates lives forever — re-running it would invalidate every encrypted DB column and every issued OAuth token.
|
||
|
||
```bash
|
||
# 1. Generate APP_KEY locally (any Laravel checkout works)
|
||
APP_KEY=$(cd /tmp && composer create-project laravel/laravel _key_gen --quiet --no-install \
|
||
&& cd _key_gen && composer install --quiet \
|
||
&& php artisan key:generate --show)
|
||
|
||
# 2. Generate Passport keys
|
||
TMP_KEYS=$(mktemp -d)
|
||
openssl genrsa -out "$TMP_KEYS/oauth-private.key" 4096
|
||
openssl rsa -in "$TMP_KEYS/oauth-private.key" -pubout -out "$TMP_KEYS/oauth-public.key"
|
||
|
||
# 3. Gather production secrets. Read each one and store in shell vars.
|
||
DB_PASS=$(openssl rand -base64 32 | tr -d '/=+' | cut -c1-32)
|
||
read -sp "STRIPE_KEY (pk_live_...): " STRIPE_KEY; echo
|
||
read -sp "STRIPE_SECRET (sk_live_...): " STRIPE_SECRET; echo
|
||
read -sp "STRIPE_WEBHOOK_SECRET (whsec_...): " STRIPE_WEBHOOK; echo
|
||
read -sp "PAYPAL_CLIENT_ID: " PAYPAL_ID; echo
|
||
read -sp "PAYPAL_CLIENT_SECRET: " PAYPAL_SECRET; echo
|
||
read -sp "AWS_ACCESS_KEY_ID (Storj): " AWS_KEY; echo
|
||
read -sp "AWS_SECRET_ACCESS_KEY (Storj): " AWS_SECRET; echo
|
||
read -sp "MAIL_USERNAME (SMTP): " MAIL_USER; echo
|
||
read -sp "MAIL_PASSWORD (SMTP): " MAIL_PASS; echo
|
||
read -sp "VIRTFUSION_API_KEY: " VF_KEY; echo
|
||
read -sp "PTERODACTYL_APP_KEY: " PTERO_KEY; echo
|
||
|
||
# 4. Create the Secret in the ezscale namespace
|
||
kubectl create secret generic ezscale-website-secrets \
|
||
--namespace ezscale \
|
||
--from-literal=APP_KEY="$APP_KEY" \
|
||
--from-literal=DB_PASSWORD="$DB_PASS" \
|
||
--from-literal=STRIPE_KEY="$STRIPE_KEY" \
|
||
--from-literal=STRIPE_SECRET="$STRIPE_SECRET" \
|
||
--from-literal=STRIPE_WEBHOOK_SECRET="$STRIPE_WEBHOOK" \
|
||
--from-literal=PAYPAL_CLIENT_ID="$PAYPAL_ID" \
|
||
--from-literal=PAYPAL_CLIENT_SECRET="$PAYPAL_SECRET" \
|
||
--from-literal=AWS_ACCESS_KEY_ID="$AWS_KEY" \
|
||
--from-literal=AWS_SECRET_ACCESS_KEY="$AWS_SECRET" \
|
||
--from-literal=MAIL_USERNAME="$MAIL_USER" \
|
||
--from-literal=MAIL_PASSWORD="$MAIL_PASS" \
|
||
--from-literal=VIRTFUSION_API_KEY="$VF_KEY" \
|
||
--from-literal=PTERODACTYL_APP_KEY="$PTERO_KEY" \
|
||
--from-file=oauth-private.key="$TMP_KEYS/oauth-private.key" \
|
||
--from-file=oauth-public.key="$TMP_KEYS/oauth-public.key"
|
||
|
||
# 5. Wipe the local key copies
|
||
rm -rf /tmp/_key_gen "$TMP_KEYS"
|
||
```
|
||
|
||
After this, `helm install` and every subsequent `helm upgrade` reference the existing Secret without ever rewriting it.
|
||
|
||
## Deploy
|
||
|
||
```bash
|
||
helm upgrade --install ezscale-website ./helm/ezscale-website \
|
||
--namespace ezscale \
|
||
-f helm/ezscale-website/values-us-prod.yaml \
|
||
--set image.tag=v0.1.0
|
||
```
|
||
|
||
## Local dev cluster
|
||
|
||
```bash
|
||
# 1. Create cluster with Longhorn (or local-path) and the operator
|
||
k3d cluster create ezscale-local
|
||
helm repo add mariadb-operator https://helm.mariadb.com/mariadb-operator
|
||
helm install mariadb-operator -n mariadb-operator --create-namespace mariadb-operator/mariadb-operator
|
||
|
||
# 2. Build the image into the cluster
|
||
docker build --target app -t ezscale-website:app-local .
|
||
docker build --target horizon -t ezscale-website:horizon-local .
|
||
docker build --target scheduler -t ezscale-website:scheduler-local .
|
||
k3d image import ezscale-website:app-local ezscale-website:horizon-local ezscale-website:scheduler-local -c ezscale-local
|
||
|
||
# 3. Edit values-local.yaml — replace APP_KEY with `php artisan key:generate --show` output
|
||
|
||
# 4. Install
|
||
helm upgrade --install ezscale-website ./helm/ezscale-website \
|
||
--namespace ezscale --create-namespace \
|
||
-f helm/ezscale-website/values-local.yaml \
|
||
--set image.registry=docker.io \
|
||
--set image.repository=library/ezscale-website \
|
||
--set image.tag=local
|
||
|
||
# 5. Port-forward and visit http://localhost:8080
|
||
kubectl port-forward -n ezscale svc/ezscale-website 8080:80
|
||
```
|
||
|
||
## Operations
|
||
|
||
```bash
|
||
# Tail Horizon logs
|
||
kubectl logs -n ezscale -l app.kubernetes.io/component=horizon -f
|
||
|
||
# Run an artisan command in a one-off pod
|
||
kubectl run -n ezscale --rm -it --image=git.ezscale.cloud/ezscale/website:app-v0.1.0 \
|
||
--env-from='[{"configMapRef":{"name":"ezscale-website-env"}},{"secretRef":{"name":"ezscale-website-secrets"}}]' \
|
||
artisan-shell -- bash
|
||
|
||
# Force a Horizon rolling restart
|
||
kubectl rollout restart deployment -n ezscale ezscale-website-horizon
|
||
```
|
||
|
||
## What's NOT in this chart (intentionally)
|
||
|
||
- Cloudflare Zero Trust for the admin panel — layered on later.
|
||
- ExternalDNS records — DNS managed manually in Cloudflare Terraform.
|
||
- Backup CronJob for the application database — covered by the existing `mariadb` instance's backup CronJob in `infrastructure/kubernetes/ezscale/mysql/`.
|
||
- Backups for Storj uploads — Storj provides its own replication; per-bucket lifecycle policies live in the Storj console.
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add helm/ezscale-website/README.md
|
||
git commit -m "docs(helm): chart README + APP_KEY/Passport bootstrap procedure
|
||
|
||
Spells out the one-time secret generation that must NEVER be re-run.
|
||
Documents local k3d setup and operations runbooks.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Gitea Actions release workflow
|
||
|
||
**Files:**
|
||
- Create: `.gitea/workflows/release.yml`
|
||
|
||
- [ ] **Step 1: Create `.gitea/workflows/release.yml`**
|
||
|
||
```yaml
|
||
name: Release
|
||
|
||
on:
|
||
push:
|
||
tags:
|
||
- 'v*'
|
||
|
||
env:
|
||
REGISTRY: git.ezscale.cloud
|
||
IMAGE_BASE: ezscale/website
|
||
|
||
jobs:
|
||
build-release:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Get version from tag
|
||
id: version
|
||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||
|
||
- name: Set up Docker Buildx
|
||
uses: docker/setup-buildx-action@v3
|
||
|
||
- name: Login to Gitea registry
|
||
uses: docker/login-action@v3
|
||
with:
|
||
registry: ${{ env.REGISTRY }}
|
||
username: ${{ github.actor }}
|
||
password: ${{ secrets.CI_TOKEN }}
|
||
|
||
- name: Build and push app image
|
||
uses: docker/build-push-action@v5
|
||
with:
|
||
context: .
|
||
target: app
|
||
push: true
|
||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-app
|
||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-app,mode=max
|
||
tags: |
|
||
${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:app-${{ steps.version.outputs.VERSION }}
|
||
${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:app-latest
|
||
|
||
- name: Build and push horizon image
|
||
uses: docker/build-push-action@v5
|
||
with:
|
||
context: .
|
||
target: horizon
|
||
push: true
|
||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-horizon
|
||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-horizon,mode=max
|
||
tags: |
|
||
${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:horizon-${{ steps.version.outputs.VERSION }}
|
||
${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:horizon-latest
|
||
|
||
- name: Build and push scheduler image
|
||
uses: docker/build-push-action@v5
|
||
with:
|
||
context: .
|
||
target: scheduler
|
||
push: true
|
||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-scheduler
|
||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-scheduler,mode=max
|
||
tags: |
|
||
${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:scheduler-${{ steps.version.outputs.VERSION }}
|
||
${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:scheduler-latest
|
||
|
||
deploy-us:
|
||
runs-on: ubuntu-latest
|
||
needs: build-release
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Get version from tag
|
||
id: version
|
||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||
|
||
- name: Install helm
|
||
uses: azure/setup-helm@v4
|
||
with:
|
||
version: v3.15.0
|
||
|
||
- name: Configure kubeconfig
|
||
run: |
|
||
mkdir -p ~/.kube
|
||
echo "${{ secrets.KUBECONFIG_US_PROD }}" | base64 -d > ~/.kube/config
|
||
chmod 600 ~/.kube/config
|
||
|
||
- name: Deploy to us-prod
|
||
run: |
|
||
helm upgrade --install ezscale-website ./helm/ezscale-website \
|
||
--namespace ezscale \
|
||
-f helm/ezscale-website/values-us-prod.yaml \
|
||
--set image.tag=${{ steps.version.outputs.VERSION }} \
|
||
--wait \
|
||
--timeout 10m
|
||
```
|
||
|
||
**Note:** Requires two repository secrets in Gitea:
|
||
- `CI_TOKEN` — Gitea token with `write:package` scope (for image push)
|
||
- `KUBECONFIG_US_PROD` — base64-encoded kubeconfig with permission to deploy into the `ezscale` namespace
|
||
|
||
- [ ] **Step 2: Validate YAML syntax**
|
||
|
||
```bash
|
||
python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/release.yml'))"
|
||
```
|
||
|
||
Expected: no output (YAML is valid).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add .gitea/workflows/release.yml
|
||
git commit -m "feat(ci): Gitea Actions release workflow on v* tags
|
||
|
||
Builds and pushes three images per tag, then runs helm upgrade
|
||
against us-prod. Cache-from/cache-to layers reuse buildx cache
|
||
across runs.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Local end-to-end smoke test
|
||
|
||
**Files:** None created — verification only.
|
||
|
||
**Context:** Spin up a k3d cluster, install the operator, install the chart, verify pods become Ready and the app responds.
|
||
|
||
- [ ] **Step 1: Create k3d cluster**
|
||
|
||
```bash
|
||
k3d cluster create ezscale-local --wait
|
||
kubectl cluster-info
|
||
```
|
||
|
||
Expected: cluster reachable.
|
||
|
||
- [ ] **Step 2: Install mariadb-operator**
|
||
|
||
```bash
|
||
helm repo add mariadb-operator https://helm.mariadb.com/mariadb-operator
|
||
helm repo update
|
||
helm install mariadb-operator -n mariadb-operator --create-namespace mariadb-operator/mariadb-operator --wait
|
||
```
|
||
|
||
Expected: operator pods Ready.
|
||
|
||
- [ ] **Step 3: Build & import images**
|
||
|
||
```bash
|
||
cd /home/andrew/local_projects/website
|
||
docker build --target app -t ezscale-website:app-local .
|
||
docker build --target horizon -t ezscale-website:horizon-local .
|
||
docker build --target scheduler -t ezscale-website:scheduler-local .
|
||
k3d image import ezscale-website:app-local ezscale-website:horizon-local ezscale-website:scheduler-local -c ezscale-local
|
||
```
|
||
|
||
Expected: three images imported.
|
||
|
||
- [ ] **Step 4: Generate APP_KEY and patch values-local.yaml**
|
||
|
||
```bash
|
||
# Generate an APP_KEY without depending on website/vendor existing.
|
||
# Format matches `php artisan key:generate --show`.
|
||
APP_KEY="base64:$(openssl rand -base64 32)"
|
||
sed -i "s|base64:CHANGEME_GENERATE_VIA_PHP_ARTISAN_KEY_GENERATE_SHOW|$APP_KEY|" helm/ezscale-website/values-local.yaml
|
||
```
|
||
|
||
Don't commit the patched values file. Reset it after the test:
|
||
|
||
```bash
|
||
git diff helm/ezscale-website/values-local.yaml | head
|
||
```
|
||
|
||
- [ ] **Step 5: Install the chart**
|
||
|
||
```bash
|
||
helm upgrade --install ezscale-website ./helm/ezscale-website \
|
||
--namespace ezscale --create-namespace \
|
||
-f helm/ezscale-website/values-local.yaml \
|
||
--set image.registry=docker.io \
|
||
--set image.repository=library/ezscale-website \
|
||
--set image.tag=local \
|
||
--wait --timeout 10m
|
||
```
|
||
|
||
Expected: install completes, no failed Job, pods rolling.
|
||
|
||
- [ ] **Step 6: Verify pods are Ready**
|
||
|
||
```bash
|
||
kubectl get pods -n ezscale
|
||
```
|
||
|
||
Expected: app, horizon, scheduler, mariadb (1 replica), valkey-0 — all `Running` and `1/1` Ready (app is `2/2` Ready). The migration Job should be `Completed`.
|
||
|
||
- [ ] **Step 7: Smoke-test the app**
|
||
|
||
```bash
|
||
kubectl port-forward -n ezscale svc/ezscale-website 8080:80 &
|
||
PF_PID=$!
|
||
sleep 3
|
||
curl -fsS http://localhost:8080/up
|
||
echo
|
||
curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:8080/
|
||
kill $PF_PID
|
||
```
|
||
|
||
Expected: `/up` returns 200 (Laravel health endpoint), `/` returns 200 or 302 (redirect to login).
|
||
|
||
- [ ] **Step 8: Reset values-local.yaml and tear down**
|
||
|
||
```bash
|
||
git checkout -- helm/ezscale-website/values-local.yaml
|
||
helm uninstall ezscale-website -n ezscale
|
||
k3d cluster delete ezscale-local
|
||
```
|
||
|
||
- [ ] **Step 9: No commit**
|
||
|
||
This task is verification-only. If anything broke, return to the failing task and fix.
|
||
|
||
---
|
||
|
||
## Final review
|
||
|
||
After all tasks above are complete:
|
||
|
||
- [ ] `helm lint helm/ezscale-website` — clean
|
||
- [ ] `helm template helm/ezscale-website -f helm/ezscale-website/values-local.yaml` — renders without error
|
||
- [ ] `helm template helm/ezscale-website -f helm/ezscale-website/values-us-prod.yaml --set image.tag=v0.1.0` — renders without error
|
||
- [ ] `docker build --target app .` — succeeds
|
||
- [ ] Local k3d e2e (Task 19) — green
|
||
- [ ] README is accurate (counts, status, repo URL)
|
||
- [ ] No top-level outdated docs left
|
||
|
||
When all green: **first production tag**:
|
||
|
||
```bash
|
||
git tag v0.1.0
|
||
git push origin v0.1.0
|
||
```
|
||
|
||
The Gitea workflow builds + pushes + deploys. Watch via `kubectl rollout status -n ezscale deploy/ezscale-website-app`.
|