Skip to main content
The default site Helm install bundles in-cluster MariaDB + Redis + MinIO subcharts so a helm install works on kind with zero prerequisites. These defaults are not for production. This page documents the recommended swaps.

Production swap matrix

ComponentDefault (dev / kind)Recommended for production
Databasebitnami/mariadb subchartMariaDB Operator
HTTP cachebitnami/redis subchartDragonflyDB Operator
Object storagebitnami/minio subchartAWS S3 / Cloudflare R2 / GCS XML
WP keys+saltsauto-generated JobExternal Secrets Operator

Why swap?

The bundled bitnami/mariadb is a single-replica StatefulSet with no automatic backups, no failover, no point-in-time recovery, and no schema migration tooling. Fine for kind, dangerous for production.MariaDB Operator provides a declarative MariaDB custom resource with replication, backups (Galera, mariabackup, mysqldump), User / Database / Grant CRDs, and Galera cluster mode for HA.
Souin uses the RESP protocol to talk to its cache backend. DragonflyDB is a drop-in Redis-protocol-compatible in-memory store with dramatically better single-node throughput (Dragonfly’s docs cite ~25× over Redis on multi-core hardware because of its lock-free shared-nothing architecture).The Dragonfly Operator deploys it declaratively. From FrankenPress’s perspective it’s just a Redis-protocol endpoint — set externalCache.host and we don’t care what’s actually behind the address.
Self-hosted object storage in your cluster is operationally costly (replication, backups, the data plane is on the same critical path as everything else). A managed S3-compatible service (AWS S3, Cloudflare R2, Google Cloud Storage XML, Backblaze B2) takes that operational burden off you and is usually cheaper at WordPress-site scale.humanmade/s3-uploads (which mu-plugin configures) talks the S3 API; it doesn’t care which provider answers.
The chart’s default keysSalts.autoGenerate: true runs a one-shot Job that creates a Secret containing the eight WP auth keys + salts on first install. Fine for instant deploy, but for production you want secrets to live in your cloud secret manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, 1Password Connect, etc.).Set keysSalts.autoGenerate: false and keysSalts.existingSecret: <name>, then have ESO sync the values into that Secret.
When you swap the bundled MinIO subchart for a real S3 (or R2 / GCS XML) bucket, configure it as below. These defaults match what mu-plugin v0.2.0+ expects.

Bucket settings

SettingValueWhy
Object OwnershipBucket owner enforced (ACLs disabled)The AWS new-bucket default since April 2023. mu-plugin v0.2.0+ omits the x-amz-acl header by default, which is exactly what this mode requires. Mismatched ACL settings on a bucket-owner-enforced bucket cause every PUT to silently abort with a 0-byte object.
Block Public AccessBlock all public access — on if media is served via CDN; off only if the bucket itself is the public originModern WP sites front media with CloudFront / Cloudflare and grant the CDN access via OAC / a bucket policy, not via per-object public ACLs.
VersioningEnabledCheap insurance against accidental overwrites or deletes. WP attachment edits (rotation, scaling) overwrite the original key on the s3-uploads stream wrapper.
Default encryptionSSE-S3 (AES256)The AWS default. SSE-KMS is fine too; KMS adds per-request cost and IAM friction for marginal threat-model benefit on public-by-design media.
RegionCo-located with the clusterAvoids cross-region GET latency on every cache miss + every WP-admin media library load.

Block Public Access — pick one

Block-public-access has four sub-toggles. Two scenarios:
  • CDN-fronted (recommended): all four toggles on. CloudFront / Cloudflare reaches the bucket via an Origin Access Control (CloudFront) or a bucket policy that whitelists the CDN’s IP ranges / service-account.
  • Bucket as public origin: “Block public ACLs” can stay on (we don’t set ACLs anyway in v0.2.0+); “Block public bucket policies” must be off, and you attach a s3:GetObject policy for * / Principal: "*".

IAM policy (minimum required)

The IAM principal humanmade/s3-uploads authenticates as needs:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::your-bucket-name"
    }
  ]
}
Notable omissions:
  • s3:PutObjectAcl is not needed — the v0.2.0+ default sends no ACL header. Granting it doesn’t hurt but it’s pointless on a bucket-owner-enforced bucket (the API call would fail with AccessControlListNotSupported).
  • s3:GetBucketLocation is not needed — humanmade/s3-uploads doesn’t issue getBucketLocation; we set the region via the FP_S3_REGION env var.

CORS (only if you serve media on a different origin)

If bucketUrl differs from WP_HOME (CDN domain, separate static host) and the WP admin’s media library uses a XHR.responseType = "blob" flow against the bucket URL, browsers enforce CORS on the fetch:
[
  {
    "AllowedOrigins": ["https://www.example.com", "https://admin.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3600
  }
]
Most WP installs don’t need CORS — the front-end fetches images via <img src> (no CORS preflight) and the admin renders thumbnails server-side. Add the policy only when a console error surfaces.

Quick verification after creating the bucket

aws s3api get-bucket-ownership-controls --bucket your-bucket-name
# Expected: ObjectOwnership: BucketOwnerEnforced

aws s3api get-public-access-block --bucket your-bucket-name
# Expected (CDN-fronted): all four flags true

aws s3api put-object --bucket your-bucket-name --key probe.txt --body /dev/null
aws s3api head-object --bucket your-bucket-name --key probe.txt
# Expected: ContentLength: 0 — but only because the body is empty.
# An upload that returns 200 with ContentLength: 0 on a non-empty
# source means the bucket is dropping content — see troubleshooting.
aws s3api delete-object --bucket your-bucket-name --key probe.txt

Migrating auto-generated Secrets to a secret manager

If you ran helm install with the chart’s defaults you already have a working site backed by auto-generated WP keys+salts and admin credentials, stored in two Secrets:
  • <release>-site-keys — the eight WP auth keys and salts
  • <release>-site-install — the wp-admin credentials (when siteInstall.autoGenerate: true)
To move to a secret-manager-backed deploy without losing your existing keys (rotating WP keys+salts logs every user out of every session and invalidates every nonce), extract the current values and seed them into your secret manager.

Extract WP keys+salts

JSON object ready to paste into a single AWS Secrets Manager / GCP Secret Manager / Vault secret:
kubectl -n <ns> get secret <release>-site-keys -o json \
  | jq '.data | with_entries(.value |= @base64d)'
Output is a flat object with the eight keys (AUTH_KEY, SECURE_AUTH_KEY, …, NONCE_SALT):
{
  "AUTH_KEY": "pkwODGDerniQOH5ybNA4...",
  "AUTH_SALT": "8KKrazylesCkl0yNdAbl...",
  "LOGGED_IN_KEY": "xsKYAiW32rQw4Z7Y3kEd...",
  ...
}
KEY=value-per-line if you’d rather pipe into something else:
kubectl -n <ns> get secret <release>-site-keys -o json \
  | jq -r '.data | to_entries[] | "\(.key)=\(.value | @base64d)"'
Push to AWS Secrets Manager in one shot:
aws secretsmanager create-secret \
  --name mysite/wp-keys \
  --secret-string "$(kubectl -n <ns> get secret <release>-site-keys -o json \
      | jq -r '.data | with_entries(.value |= @base64d)')"
Then point the chart at the ESO-synced Secret:
keysSalts:
  autoGenerate: false
  existingSecret: mysite-wp-keys

Extract admin install credentials

Same shape:
kubectl -n <ns> get secret <release>-site-install -o json \
  | jq '.data | with_entries(.value |= @base64d)'
Yields admin_user / admin_email / admin_password. Push those into your secret manager, then:
siteInstall:
  existingSecret: mysite-admin                    # ESO-synced

syncAdminCredentials: true                        # default — initContainer reconciles wp_users on every Pod start
                                                  # pair with Reloader for self-driving rotation:
                                                  # /operations/admin-credential-rotation

Other Secrets

Same kubectl get secret -o json | jq pattern works for every Secret the chart consumes:
Auto-generated SecretUsed byMigrate to
<release>-site-keysAll pods (env: 8 keys+salts)keysSalts.existingSecret
<release>-site-installInstall Job (admin creds)siteInstall.existingSecret
<release>-mariadbAll pods (DB_PASSWORD)externalDatabase.existingSecret (after disabling the subchart)
<release>-minioAll pods (FP_S3_KEY/FP_S3_SECRET)externalS3.existingSecret (keys: access-key, secret-key)
The MariaDB and MinIO subchart Secrets only exist while their respective subchart is enabled: true — for production you’ll provision the DB / object store separately and supply credentials via your secret manager from day one.

End-to-end production values

# values-prod.yaml
image:
  repository: ghcr.io/your-org/your-site
  tag: v1.0.0

site:
  url: https://mysite.example.com
  env: production

replicaCount: 3
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

ingress:
  enabled: true
  className: nginx
  hostname: mysite.example.com
  tls: true
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod

# --- disable in-cluster subcharts ---
mariadb:
  enabled: false
redis:
  enabled: false
minio:
  enabled: false

# --- production endpoints ---
externalDatabase:
  host: mysite-primary.databases.svc.cluster.local  # MariaDB Operator endpoint
  port: 3306
  database: mysite
  user: mysite
  existingSecret: mysite-db-credentials             # ESO-managed
  existingSecretPasswordKey: password

externalCache:
  host: mysite-cache.databases.svc.cluster.local    # Dragonfly Operator endpoint
  port: 6379
  # password optional

externalS3:
  bucket: mysite-media-prod
  region: eu-west-1
  bucketUrl: https://cdn.mysite.example.com         # CloudFront / Cloudflare in front of bucket
  existingSecret: mysite-s3-credentials             # ESO-managed; keys: access-key, secret-key

# --- WP secrets via External Secrets Operator ---
keysSalts:
  autoGenerate: false
  existingSecret: mysite-wp-keys                    # ESO syncs from your secret manager
The chart’s defaults already satisfy PodSecurity Standard restricted:latestrunAsNonRoot: true, runAsUser: 33, fsGroup: 33, seccompProfile.type: RuntimeDefault at the pod level; allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities.drop: [ALL] per container — so production overrides above don’t need to repeat them. If you build a custom runtime that re-adds the cap_net_bind_service file capability, override containerSecurityContext.allowPrivilegeEscalation: true and containerSecurityContext.capabilities.add: [NET_BIND_SERVICE] (the kernel’s no_new_privs flag would otherwise refuse to exec the binary). The published runtime strips the cap, so this only matters for forked images. Install:
helm install mysite oci://ghcr.io/frankenpress/charts/site \
  --namespace mysite --create-namespace \
  --values values-prod.yaml

Image promotion across environments

The same site image (e.g. ghcr.io/your-org/your-site:v1.0.0) is promoted from staging → production. Only values-prod.yaml differs — same code, different config. Use whatever GitOps tooling you prefer (Argo CD, Flux, Kargo for tag promotion + verification).FrankenPress doesn’t ship its own GitOps glue — the chart renders k8s primitives that any orchestrator can consume.

Multi-site

For multiple sites in one cluster, each gets its own:
  • Namespace
  • Helm release (helm install <release-name> ...)
  • DB / cache / S3 endpoints (or shared infrastructure with per-site DBs / buckets)
  • Secret with WP keys+salts
The chart is single-tenant by design. Multi-tenant features (per-site URL routing in one Deployment, etc.) are explicitly out of scope.