Skip to main content

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-dns annotation automatically creates DNS records
  • No secretName is 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.