Skip to main content

Create the Tenant from Scratch

What You Will Learn

  • How to create the tenant directory and namespace YAML
  • How to write secrets into HashiCorp Vault using the CLI
  • How to create ExternalSecret resources that pull secrets from Vault into Kubernetes
  • How each field in an ExternalSecret maps to a Vault path and a Kubernetes Secret key

Step 1: Create the Directory

First, create the tenant directory inside the Flux repo:

# Navigate to the clients directory in the Flux repo
cd ~/workspace/junovy/dds-k8s-cluster/clients

# Create the tenant directory
mkdir -p com.junovy.radical-faeries

This is where all YAML files for this tenant will live. One directory, one tenant.


Step 2: The Namespace (010-namespace.yaml)

Every tenant starts with a namespace. Create 010-namespace.yaml:

# 010-namespace.yaml
# Creates the isolated namespace for the Radical Faeries tenant.
# All their apps, secrets, and networking live inside this namespace.
apiVersion: v1
kind: Namespace
metadata:
  name: hst-radical-faeries          # hst- prefix = hosting tier
  labels:
    tier: hosting                     # Identifies this as a hosting tenant
    customer: radical-faeries         # Human-readable tenant name
    plan: basic                       # Service tier (free, basic, pro)
    managed-by: junovy                # Marks this as platform-managed
    app.kubernetes.io/part-of: junovy-clients  # Groups all client namespaces
    ecr-refresh: "enabled"            # Auto-refreshes ECR pull credentials

The labels are important. They let you filter and query namespaces later. The ecr-refresh label ensures the namespace gets fresh AWS ECR pull credentials automatically.


Step 3: Store Secrets in Vault

Before Kubernetes can use secrets, they need to exist in Vault. You will run these commands from a terminal with VPN access.

BookStack Secrets

# Store all BookStack secrets under a single Vault path
# Each key=value pair becomes a property you can reference later
vault kv put secret/radical-faeries/bookstack \
  app-key="base64:$(openssl rand -base64 32)" \
  db-password="$(openssl rand -base64 24)" \
  smtp-host="email-smtp.eu-central-1.amazonaws.com" \
  smtp-port="587" \
  smtp-username="YOUR_SES_SMTP_USERNAME" \
  smtp-password="YOUR_SES_SMTP_PASSWORD" \
  smtp-from-email="noreply@radical-faeries.junovy.com"

Rallly Secrets

# Generate a secure password for the Rallly database user
RALLLY_DB_PASS="$(openssl rand -base64 24)"

# Store all Rallly secrets under a single Vault path
vault kv put secret/radical-faeries/rallly \
  database-url="postgresql://rallly:${RALLLY_DB_PASS}@postgresql.postgresql.svc.cluster.local:5432/rallly_radical_faeries" \
  secret-password="$(openssl rand -base64 32)" \
  smtp-host="email-smtp.eu-central-1.amazonaws.com" \
  smtp-port="587" \
  smtp-username="YOUR_SES_SMTP_USERNAME" \
  smtp-password="YOUR_SES_SMTP_PASSWORD" \
  smtp-from-email="noreply@radical-faeries.junovy.com"

Verify the Secrets

After writing them, confirm they are stored:

# Read back BookStack secrets (shows all keys and values)
vault kv get secret/radical-faeries/bookstack

# Read back Rallly secrets
vault kv get secret/radical-faeries/rallly

You should see all the keys you just stored listed in the output. If a key is missing, run the vault kv put command again -- it overwrites the entire path, so include all keys every time.


Step 4: BookStack ExternalSecret (020-externalsecret-bookstack.yaml)

An ExternalSecret tells the External Secrets Operator: "Go to Vault, read these values, and create a Kubernetes Secret from them." Here is the annotated YAML:

# 020-externalsecret-bookstack.yaml
# Pulls BookStack secrets from HashiCorp Vault and creates a K8s Secret.
# The HelmRelease will reference this Secret for environment variables.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: bookstack-secrets             # Name of this ExternalSecret resource
  namespace: hst-radical-faeries      # Must match the tenant namespace
spec:
  refreshInterval: 1h                 # Re-sync from Vault every hour
  secretStoreRef:
    name: vault                       # References the ClusterSecretStore named "vault"
    kind: ClusterSecretStore          # Cluster-wide (not namespace-scoped)
  target:
    name: bookstack-secrets           # Name of the K8s Secret that gets created
    creationPolicy: Owner             # ExternalSecret owns the Secret (deletes with it)
  data:
    # Each entry maps: Vault path + property → K8s Secret key
    #
    # How to read this:
    #   remoteRef.key    = the Vault path (after secret/)
    #   remoteRef.property = the specific key within that path
    #   secretKey        = the key name in the resulting K8s Secret

    # Application encryption key (Laravel APP_KEY)
    - secretKey: app-key
      remoteRef:
        key: radical-faeries/bookstack
        property: app-key

    # MySQL database password
    - secretKey: db-password
      remoteRef:
        key: radical-faeries/bookstack
        property: db-password

    # SMTP mail configuration
    - secretKey: smtp-host
      remoteRef:
        key: radical-faeries/bookstack
        property: smtp-host
    - secretKey: smtp-port
      remoteRef:
        key: radical-faeries/bookstack
        property: smtp-port
    - secretKey: smtp-username
      remoteRef:
        key: radical-faeries/bookstack
        property: smtp-username
    - secretKey: smtp-password
      remoteRef:
        key: radical-faeries/bookstack
        property: smtp-password
    - secretKey: smtp-from-email
      remoteRef:
        key: radical-faeries/bookstack
        property: smtp-from-email

How the Mapping Works

Here is the flow for a single secret entry:

Vault                              ExternalSecret                    K8s Secret
─────                              ──────────────                    ──────────
secret/radical-faeries/bookstack   remoteRef:                        data:
  └── db-password: "abc123"          key: radical-faeries/bookstack    └── db-password: "YWJjMTIz"
                                     property: db-password                  (base64 encoded)

Vault stores the plaintext value. The ExternalSecret reads it. Kubernetes stores it base64-encoded in a Secret object. Your app sees the plaintext value when it reads the Secret.


Step 5: Rallly ExternalSecret (021-externalsecret-rallly.yaml)

Same pattern, different app:

# 021-externalsecret-rallly.yaml
# Pulls Rallly secrets from HashiCorp Vault and creates a K8s Secret.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: rallly-secrets
  namespace: hst-radical-faeries
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault
    kind: ClusterSecretStore
  target:
    name: rallly-secrets
    creationPolicy: Owner
  data:
    # Full PostgreSQL connection string
    # Format: postgresql://user:password@host:port/database
    - secretKey: database-url
      remoteRef:
        key: radical-faeries/rallly
        property: database-url

    # Session encryption key (used by Rallly for secure cookies)
    - secretKey: secret-password
      remoteRef:
        key: radical-faeries/rallly
        property: secret-password

    # SMTP mail configuration
    - secretKey: smtp-host
      remoteRef:
        key: radical-faeries/rallly
        property: smtp-host
    - secretKey: smtp-port
      remoteRef:
        key: radical-faeries/rallly
        property: smtp-port
    - secretKey: smtp-username
      remoteRef:
        key: radical-faeries/rallly
        property: smtp-username
    - secretKey: smtp-password
      remoteRef:
        key: radical-faeries/rallly
        property: smtp-password
    - secretKey: smtp-from-email
      remoteRef:
        key: radical-faeries/rallly
        property: smtp-from-email

Notice the structure is identical to the BookStack ExternalSecret. The only differences are the names and the Vault path. This consistency is intentional -- once you learn the pattern, every ExternalSecret looks the same.


Step 6: The Kustomization (kustomization.yaml)

The kustomization file ties everything together. For now, it lists the namespace and secrets. You will add more resources in the next page.

# kustomization.yaml
# Master resource list for the Radical Faeries tenant.
# Flux reads this file to know what to deploy.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: hst-radical-faeries        # Applied to all resources in this directory

resources:
  - 010-namespace.yaml                # Namespace (created first)
  - 020-externalsecret-bookstack.yaml # BookStack secrets from Vault
  - 021-externalsecret-rallly.yaml    # Rallly secrets from Vault

You will add the HelmRelease and Ingress files to this list in the next page.


What You Have So Far

At this point, your directory contains:

com.junovy.radical-faeries/
├── kustomization.yaml
├── 010-namespace.yaml
├── 020-externalsecret-bookstack.yaml
└── 021-externalsecret-rallly.yaml

And in Vault:

secret/radical-faeries/bookstack  →  app-key, db-password, smtp-*
secret/radical-faeries/rallly     →  database-url, secret-password, smtp-*

The foundation is in place. The namespace exists, the secrets are stored, and ExternalSecrets know how to pull them into Kubernetes. Next, you will deploy the actual applications.


Key Takeaways

  • Start with the namespace (010) -- everything else depends on it
  • Store secrets in Vault using vault kv put with all keys in one command
  • ExternalSecrets map Vault paths to Kubernetes Secret keys automatically
  • The remoteRef.key is the Vault path (without the secret/ prefix) and remoteRef.property is the specific key
  • The kustomization.yaml lists every resource Flux should deploy for this tenant

What Is Next

Next up: Deploy Apps for the Faeries where you will write HelmRelease manifests for BookStack and Rallly and create Ingress resources for public access.