Deploy Apps for the Faeries
What You Will Learn
- How to write a HelmRelease that tells Flux to install a Helm chart
- How Helm values map to running containers and their environment variables
- How to create Ingress resources with automatic TLS via Traefik
- How to update the kustomization.yaml with the complete resource list
What Is a HelmRelease?
A HelmRelease is a Flux resource that says: "Install this Helm chart with these settings." Flux watches the Git repo, sees the HelmRelease, and tells Helm to install or upgrade the app. You do not run helm install manually -- Flux handles it.
Think of it like a recipe card. The HelmRelease says which cookbook (chart repo), which recipe (chart name), and which ingredient tweaks (values) to use. Flux is the chef.
BookStack HelmRelease (040-helmrelease-bookstack.yaml)
# 040-helmrelease-bookstack.yaml
# Deploys BookStack (wiki) with a MySQL sidecar database.
# Flux reads this and tells Helm to install the bookstack chart.
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: bookstack # Name of this release
namespace: hst-radical-faeries # Deploy into the tenant namespace
spec:
releaseName: bookstack # Helm release name (shows in `helm list`)
chart:
spec:
chart: bookstack # Chart name in the repository
version: "1.4.0" # Pin to a specific chart version
sourceRef:
kind: HelmRepository
name: junovy-charts # References a HelmRepository in flux-system
namespace: flux-system # HelmRepositories live in flux-system
interval: 5m # Check for drift every 5 minutes
timeout: 10m # Give installs up to 10 minutes
install:
remediation:
retries: 3 # Retry failed installs up to 3 times
upgrade:
remediation:
retries: 3 # Retry failed upgrades up to 3 times
values:
# ── Image Configuration ──
image:
repository: 345756855967.dkr.ecr.eu-central-1.amazonaws.com/bookstack
tag: "24.10" # BookStack version
pullPolicy: IfNotPresent # Use cached image if available
imagePullSecrets:
- name: ecr-dockerconfig # AWS ECR credentials (auto-refreshed)
# ── Application Settings ──
env:
# Public URL (used for generating links in emails and pages)
- name: APP_URL
value: "https://bookstack.radical-faeries.junovy.com"
# Application encryption key (from Vault via ExternalSecret)
- name: APP_KEY
valueFrom:
secretKeyRef:
name: bookstack-secrets # Matches ExternalSecret target.name
key: app-key # Matches ExternalSecret secretKey
# ── Database Configuration ──
# BookStack's MySQL runs as a sidecar, so the host is localhost
- name: DB_HOST
value: "127.0.0.1"
- name: DB_PORT
value: "3306"
- name: DB_DATABASE
value: "bookstack"
- name: DB_USERNAME
value: "bookstack"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: db-password
# ── Mail Configuration ──
- name: MAIL_DRIVER
value: "smtp"
- name: MAIL_HOST
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: smtp-host
- name: MAIL_PORT
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: smtp-port
- name: MAIL_USERNAME
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: smtp-username
- name: MAIL_PASSWORD
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: smtp-password
- name: MAIL_FROM
valueFrom:
secretKeyRef:
name: bookstack-secrets
key: smtp-from-email
- name: MAIL_ENCRYPTION
value: "tls"
# ── MySQL Sidecar ──
# The chart runs MySQL as a sidecar container in the same Pod
mysql:
enabled: true # Enable the MySQL sidecar
password:
existingSecret: bookstack-secrets
key: db-password # Reuse the same Vault-managed password
# ── Storage ──
persistence:
enabled: true
size: 5Gi # Storage for uploads and attachments
storageClass: nfs-client # NFS for shared storage across nodes
# ── Resource Limits ──
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
# ── Service ──
service:
port: 8080 # BookStack listens on 8080
How Values Reach the Container
Here is how a single value flows from this file to the running app:
HelmRelease values.env → Pod spec env → Container env var
───────────────────────── ────────────────── ───────────────────
name: APP_URL env: APP_URL=https://bookstack...
value: "https://bookstack..." - name: APP_URL
value: "https://..."
For secrets, the flow adds one more step:
Vault → ExternalSecret → K8s Secret → Pod env (valueFrom)
───── ────────────── ────────── ──────────────────────
secret/.../bookstack secretKey: data: DB_PASSWORD=<plaintext>
db-password: "abc" db-password db-password: YWJj
Rallly HelmRelease (041-helmrelease-rallly.yaml)
# 041-helmrelease-rallly.yaml
# Deploys Rallly (meeting scheduler) connecting to the shared PostgreSQL cluster.
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: rallly
namespace: hst-radical-faeries
spec:
releaseName: rallly
chart:
spec:
chart: rallly
version: "1.2.0"
sourceRef:
kind: HelmRepository
name: junovy-charts
namespace: flux-system
interval: 5m
timeout: 10m
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
values:
# ── Image Configuration ──
image:
repository: 345756855967.dkr.ecr.eu-central-1.amazonaws.com/rallly
tag: "3.10"
pullPolicy: IfNotPresent
imagePullSecrets:
- name: ecr-dockerconfig
# ── Application Settings ──
env:
# Public base URL (used for generating links in emails)
- name: NEXT_PUBLIC_BASE_URL
value: "https://rallly.radical-faeries.junovy.com"
# PostgreSQL connection string (from Vault via ExternalSecret)
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: rallly-secrets # Matches ExternalSecret target.name
key: database-url # Full connection string from Vault
# Session encryption key
- name: SECRET_PASSWORD
valueFrom:
secretKeyRef:
name: rallly-secrets
key: secret-password
# ── Mail Configuration ──
- name: SMTP_HOST
valueFrom:
secretKeyRef:
name: rallly-secrets
key: smtp-host
- name: SMTP_PORT
valueFrom:
secretKeyRef:
name: rallly-secrets
key: smtp-port
- name: SMTP_USER
valueFrom:
secretKeyRef:
name: rallly-secrets
key: smtp-username
- name: SMTP_PWD
valueFrom:
secretKeyRef:
name: rallly-secrets
key: smtp-password
- name: SUPPORT_EMAIL
valueFrom:
secretKeyRef:
name: rallly-secrets
key: smtp-from-email
- name: SMTP_SECURE
value: "tls"
# Node.js environment
- name: NODE_ENV
value: "production"
# ── Resource Limits ──
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "250m"
# ── Service ──
service:
port: 3000 # Rallly listens on 3000
Notice Rallly is lighter than BookStack. No sidecar database, no persistent storage. It connects to the shared PostgreSQL cluster using the DATABASE_URL connection string stored in Vault.
BookStack Ingress (050-ingress-bookstack.yaml)
The Ingress resource tells Traefik: "Route traffic for this domain to this service." It also handles TLS automatically.
# 050-ingress-bookstack.yaml
# Routes external traffic to the BookStack service with automatic TLS.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bookstack-ingress
namespace: hst-radical-faeries
annotations:
# Use Traefik's built-in Let's Encrypt certificate resolver
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
# Use the HTTPS entrypoint (HTTP automatically redirects to HTTPS)
traefik.ingress.kubernetes.io/router.entrypoints: websecure
# Rate limiting middleware (namespace-prefixed format required by Traefik)
traefik.ingress.kubernetes.io/router.middlewares: hst-radical-faeries-base-rate-limit@kubernetescrd
# Tell external-dns to create a DNS record for this domain
external-dns.alpha.kubernetes.io/hostname: bookstack.radical-faeries.junovy.com
spec:
ingressClassName: traefik-external # Use the external Traefik instance
tls:
- hosts:
- bookstack.radical-faeries.junovy.com
# No secretName needed -- Traefik ACME stores certs in acme.json
rules:
- host: bookstack.radical-faeries.junovy.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bookstack # Must match the Service name from HelmRelease
port:
number: 8080 # Must match the Service port
What Each Annotation Does
| Annotation | Purpose |
|---|---|
router.tls.certresolver: letsencrypt |
Traefik automatically gets a TLS certificate from Let's Encrypt |
router.entrypoints: websecure |
Route traffic through the HTTPS entrypoint (port 443) |
router.middlewares: ... |
Apply rate limiting to prevent abuse |
external-dns...hostname: ... |
Automatically create a DNS A record pointing to the cluster |
Rallly Ingress (051-ingress-rallly.yaml)
Same pattern, different domain and port:
# 051-ingress-rallly.yaml
# Routes external traffic to the Rallly service with automatic TLS.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: rallly-ingress
namespace: hst-radical-faeries
annotations:
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: hst-radical-faeries-base-rate-limit@kubernetescrd
external-dns.alpha.kubernetes.io/hostname: rallly.radical-faeries.junovy.com
spec:
ingressClassName: traefik-external
tls:
- hosts:
- rallly.radical-faeries.junovy.com
rules:
- host: rallly.radical-faeries.junovy.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: rallly
port:
number: 3000
Updated Kustomization (kustomization.yaml)
Now add all the new resources to the kustomization file:
# kustomization.yaml
# Complete resource list for the Radical Faeries tenant.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: hst-radical-faeries
resources:
# 010 - Namespace (created first)
- 010-namespace.yaml
# 020 - Secrets (must exist before apps reference them)
- 020-externalsecret-bookstack.yaml
- 021-externalsecret-rallly.yaml
# 040 - Applications (depend on secrets)
- 040-helmrelease-bookstack.yaml
- 041-helmrelease-rallly.yaml
# 050 - Networking (depend on app services)
- 050-ingress-bookstack.yaml
- 051-ingress-rallly.yaml
The Complete Directory
Your tenant directory should now look like this:
com.junovy.radical-faeries/
├── kustomization.yaml
├── 010-namespace.yaml
├── 020-externalsecret-bookstack.yaml
├── 021-externalsecret-rallly.yaml
├── 040-helmrelease-bookstack.yaml
├── 041-helmrelease-rallly.yaml
├── 050-ingress-bookstack.yaml
└── 051-ingress-rallly.yaml
Eight files. Two apps. All declarative. All version-controlled. All deployed automatically by Flux.
Key Takeaways
- A HelmRelease tells Flux which chart to install and what values to use
- Environment variables in the HelmRelease connect to Kubernetes Secrets via
valueFrom.secretKeyRef - Ingress resources route external traffic to services and handle TLS automatically via Traefik
- The
external-dnsannotation automatically creates DNS records - No
secretNameis needed in the TLS block -- Traefik's ACME resolver manages certificates internally - The kustomization.yaml lists all resources in dependency order
What Is Next
Next up: Verify and Handoff where you will push everything to Git, watch Flux deploy it, verify the apps are running, and hand off the URLs to the client.