> ## Documentation Index
> Fetch the complete documentation index at: https://docs.frankenpress.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Production topology

> Replace in-cluster subcharts with managed services for production deploys

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

| Component      | Default (dev / kind)       | Recommended for production                                                |
| -------------- | -------------------------- | ------------------------------------------------------------------------- |
| Database       | `bitnami/mariadb` subchart | [MariaDB Operator](https://github.com/mariadb-operator/mariadb-operator)  |
| HTTP cache     | `bitnami/redis` subchart   | [DragonflyDB Operator](https://github.com/dragonflydb/dragonfly-operator) |
| Object storage | `bitnami/minio` subchart   | AWS S3 / Cloudflare R2 / GCS XML                                          |
| WP keys+salts  | auto-generated `Job`       | [External Secrets Operator](https://external-secrets.io/)                 |

## Why swap?

<AccordionGroup>
  <Accordion title="MariaDB Operator vs the bundled subchart" icon="database">
    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](https://github.com/mariadb-operator/mariadb-operator)
    provides a declarative `MariaDB` custom resource with replication,
    backups (Galera, mariabackup, `mysqldump`), `User` / `Database` /
    `Grant` CRDs, and Galera cluster mode for HA.
  </Accordion>

  <Accordion title="DragonflyDB vs Redis" icon="dragon">
    Souin uses the RESP protocol to talk to its cache backend.
    [DragonflyDB](https://www.dragonflydb.io/) 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](https://github.com/dragonflydb/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.
  </Accordion>

  <Accordion title="AWS S3 (or R2 / GCS) vs in-cluster MinIO" icon="aws">
    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.
  </Accordion>

  <Accordion title="External Secrets Operator vs the auto-generated Job" icon="key">
    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.
  </Accordion>
</AccordionGroup>

## Recommended S3 bucket configuration

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`](/components/mu-plugin) v0.2.0+ expects.

### Bucket settings

| Setting                 | Value                                                                                                           | Why                                                                                                                                                                                                                                                                                                                     |
| ----------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Object Ownership**    | **Bucket 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](/operations/troubleshooting#0-byte-uploads-to-s3). |
| **Block Public Access** | Block all public access — **on** if media is served via CDN; off only if the bucket itself is the public origin | Modern WP sites front media with CloudFront / Cloudflare and grant the CDN access via OAC / a bucket policy, not via per-object public ACLs.                                                                                                                                                                            |
| **Versioning**          | Enabled                                                                                                         | Cheap insurance against accidental overwrites or deletes. WP attachment edits (rotation, scaling) overwrite the original key on the s3-uploads stream wrapper.                                                                                                                                                          |
| **Default encryption**  | SSE-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.                                                                                                                                                                           |
| **Region**              | Co-located with the cluster                                                                                     | Avoids 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:

```json theme={null}
{
  "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:

```json theme={null}
[
  {
    "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

```bash theme={null}
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 `Secret`s:

* `<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:

```bash theme={null}
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`):

```json theme={null}
{
  "AUTH_KEY": "pkwODGDerniQOH5ybNA4...",
  "AUTH_SALT": "8KKrazylesCkl0yNdAbl...",
  "LOGGED_IN_KEY": "xsKYAiW32rQw4Z7Y3kEd...",
  ...
}
```

`KEY=value`-per-line if you'd rather pipe into something else:

```bash theme={null}
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:

```bash theme={null}
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:

```yaml theme={null}
keysSalts:
  autoGenerate: false
  existingSecret: mysite-wp-keys
```

### Extract admin install credentials

Same shape:

```bash theme={null}
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:

```yaml theme={null}
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 Secret    | Used by                               | Migrate to                                                       |
| ------------------------ | ------------------------------------- | ---------------------------------------------------------------- |
| `<release>-site-keys`    | All pods (env: 8 keys+salts)          | `keysSalts.existingSecret`                                       |
| `<release>-site-install` | Install Job (admin creds)             | `siteInstall.existingSecret`                                     |
| `<release>-mariadb`      | All pods (`DB_PASSWORD`)              | `externalDatabase.existingSecret` (after disabling the subchart) |
| `<release>-minio`        | All 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

```yaml theme={null}
# 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:latest` —
`runAsNonRoot: 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:

```bash theme={null}
helm install mysite oci://ghcr.io/frankenpress/charts/site \
  --namespace mysite --create-namespace \
  --values values-prod.yaml
```

## Image promotion across environments

<Tip>
  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](https://argo-cd.readthedocs.io/),
  [Flux](https://fluxcd.io/),
  [Kargo](https://kargo.akuity.io/) for tag promotion + verification).

  FrankenPress doesn't ship its own GitOps glue — the chart renders
  k8s primitives that any orchestrator can consume.
</Tip>

## 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.
