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

# Admin credential rotation

> Self-driving WordPress admin password rotation via the syncAdminCredentials initContainer + Reloader

WordPress stores a *hash* of the admin password in `wp_users`, so updating
a Secret alone doesn't change the live login. The `site` chart ships
with a credential-sync **initContainer** on every site Pod that reads the
install Secret and reconciles `wp_users` on Pod start. Pair it with
[Stakater Reloader](/operations/email#auto-rolling-on-secret-change-reloader)
and rotation is self-driving end-to-end.

## End-to-end flow

```text theme={null}
secret manager (ASM / GCP SM / Vault)
   │  rotation policy fires (or operator runs a manual update)
   ▼
External Secrets Operator
   │  pulls new value into K8s Secret
   ▼
<release>-site-install Secret  (admin_password key updated)
   │  Stakater Reloader watches the Deployment's referenced Secrets
   ▼
Deployment rolling restart  (default strategy: maxSurge:1, maxUnavailable:0)
   │  Pod template recreates → initContainer runs first
   ▼
sync-admin-credentials initContainer
   │  reads new admin_password from env, compares against wp_users
   │  → idempotency check (wp_check_password) detects drift
   │  → wp user update writes new hash
   ▼
Site container starts. wp_users now matches Secret.
```

No `helm upgrade`, no `argocd app sync`, no `kubectl exec`. Available in
chart **v0.4.0+**.

## How the initContainer works

Every site Pod runs a `sync-admin-credentials` initContainer before the
main `site` container. The initContainer:

<Steps>
  <Step title="Wait for the database">
    Direct `mysqli` probe (doesn't load WordPress) up to 60×2s. The
    same probe the install Job uses — works against fresh DBs that
    haven't seen `wp core install` yet.
  </Step>

  <Step title="Skip on fresh installs">
    If `wp core is-installed` returns false, exit 0. The post-install
    Helm hook Job is responsible for first-time bootstrap; the
    initContainer would have nothing to reconcile.
  </Step>

  <Step title="Idempotency check">
    Resolve the target user (prefer `admin_email`, fall back to
    `admin_user`). Compare the desired credentials against the current
    DB state via `wp eval`:

    * `wp_check_password(getenv("ADMIN_PASSWORD"), $u->user_pass, $u->ID)` — hash compare
    * `$u->user_email === getenv("ADMIN_EMAIL")` — string compare
    * `$u->user_login === getenv("ADMIN_USER")` — string compare

    All three match → exit 0 with `[sync-creds] DB already in sync with Secret; skipping update`.
  </Step>

  <Step title="Reconcile on drift">
    Any mismatch → run `wp user update --user_pass --user_email`. Pod
    proceeds; site container starts.
  </Step>
</Steps>

## Multi-replica idempotency

With `replicaCount > 1`, every Pod runs the initContainer on start. The
default rolling-update strategy (`maxSurge: 1, maxUnavailable: 0`) brings
new Pods up sequentially, so:

* The **first** Pod to roll detects drift, runs `wp user update`. WP
  fires its "Password Changed" notification (one email).
* **Every subsequent** Pod sees the DB already-in-sync and short-circuits
  before calling `wp user update`. No DB write. No notification.

Net: **one DB write per rotation**, **at most one WP-emitted "Password
Changed" email** regardless of replica count.

This is why the idempotency check matters — without it, every replica
would race to `wp user update` on every roll, generating noise and
duplicate emails.

## Reloader setup

The initContainer only runs when the Pod restarts. To turn an updated
Secret into a Pod restart automatically, install
[**Stakater Reloader**](https://github.com/stakater/Reloader) cluster-wide
and annotate the Deployment.

Full setup is in [Operations → Email → Auto-rolling on Secret change](/operations/email#auto-rolling-on-secret-change-reloader)
— Reloader is platform-wide infrastructure, not feature-specific. Once
installed, the same controller solves rotation for the SMTP token, DB
password, WP keys+salts, and the admin Secret.

For admin-credential rotation specifically, watch the install Secret
via the chart's typed `reloader` block (chart **v0.13.0+**):

```yaml theme={null}
# values.yaml
reloader:
  enabled: true
  secretsToWatch:
    - <release>-site-install
```

The chart emits a `secret.reloader.stakater.com/reload: "<csv>"`
annotation on the **Deployment** (the only place Reloader actually
looks — see Failure modes below).

For sites that want any referenced Secret/ConfigMap change to roll
the Deployment, fall back to the pre-v0.13 `commonAnnotations` form:

```yaml theme={null}
commonAnnotations:
  reloader.stakater.com/auto: "true"
```

<Note>
  For the auto-generated install Secret the name is
  `<release>-site-install`. With `siteInstall.existingSecret` set,
  use whatever name you supplied.
</Note>

## Verifying a rotation took effect

After the Pod has rolled, check the live DB user from any running Pod:

```bash theme={null}
# Resolve current admin user from the Secret
ADMIN_EMAIL=$(kubectl -n <ns> get secret <release>-site-install \
  -o jsonpath='{.data.admin_email}' | base64 -d)

# Confirm wp_users has the new email (and was touched recently)
kubectl -n <ns> exec deploy/<release>-site -c site -- \
  wp --allow-root --path=/app/web/wp user get "$ADMIN_EMAIL" \
  --fields=ID,user_login,user_email,user_registered
```

For password verification, evaluate `wp_check_password` against the
expected value in-pod:

```bash theme={null}
EXPECTED=$(kubectl -n <ns> get secret <release>-site-install \
  -o jsonpath='{.data.admin_password}' | base64 -d)

kubectl -n <ns> exec deploy/<release>-site -c site -- \
  env EXPECTED="$EXPECTED" wp --allow-root --path=/app/web/wp eval '
    $u = get_user_by("email", getenv("WP_ADMIN_EMAIL")) ?: get_user_by("login", "admin");
    echo wp_check_password(getenv("EXPECTED"), $u->user_pass, $u->ID) ? "ok\n" : "mismatch\n";
  '
```

`ok` confirms the live DB hash matches the Secret value. `mismatch`
means rotation hasn't propagated yet — check the Pod has actually
rolled (`kubectl rollout status deployment/<release>-site`) and
that Reloader sees the Secret (`kubectl logs -n reloader deploy/reloader-reloader`).

For a full e2e (rotate + assert hash matches across all replicas), see
[`tests/credential-sync-test.sh`](https://github.com/frankenpress/charts/blob/main/tests/credential-sync-test.sh)
in `charts`.

## Failure modes

| State                                 | Behaviour                                                                                                                                                                                                                                                                                                                                                                                         |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Reloader not installed                | Secret update doesn't roll the Pod. The initContainer never re-runs, so wp\_users drifts from the Secret until something else triggers a roll (`kubectl rollout restart`, `helm upgrade`, autoscaler scale-up, etc.). Install Reloader.                                                                                                                                                           |
| Reloader watching the wrong key       | Pre-v0.13 charts: annotation set on `podAnnotations` instead of `commonAnnotations`. Reloader watches the Deployment object's `metadata.annotations`, not the pod-template's. Switch to the typed `reloader.{enabled, secretsToWatch}` block (chart v0.13.0+) which emits on the Deployment automatically.                                                                                        |
| Pod stuck `Init:Error` after rotation | The initContainer ran but failed (typically a wp-cli error visible in `kubectl logs <pod> -c sync-admin-credentials`). The Deployment never becomes Ready, the rollout times out, old replicas keep serving with the old password. Investigate the log; the most likely cause is the admin user not being resolvable by either email or username (e.g. someone manually renamed it via wp-admin). |
| `smtp.enabled=false` (default)        | Each rotation suppresses WP's password-change notification via a `WP_CLI::add_hook( 'after_wp_load', ... )` filter file. The site container's view of `wp_mail()` is unchanged.                                                                                                                                                                                                                   |
| `smtp.enabled=true`                   | WP fires its built-in "Password Changed" / "Email Changed" notification when `wp user update` writes. **Expected behaviour** — site authors and ops both like seeing rotation in their inbox. To suppress globally, install a `wp_mail` filter mu-plugin (out-of-scope for this chart; see [What's not in the platform](#whats-not-in-the-platform)).                                             |
| Drifted manual edit via wp-admin      | If an operator changed the admin password in wp-admin without updating the Secret, the next Pod roll detects drift and overwrites their manual change with the Secret's value. The Secret is the source of truth — set `syncAdminCredentials: false` if you want the in-DB value to win.                                                                                                          |

## Opting out

Set `syncAdminCredentials: false` to skip the initContainer entirely:

```yaml theme={null}
syncAdminCredentials: false
```

`helm template` confirms no initContainer block renders. The admin
user becomes whatever `wp core install` set on first install — never
touched again by the chart.

When to opt out:

* **External IdP owns the WP admin user.** SSO plugin (e.g. SAML,
  LDAP, OIDC) provisions and updates wp\_users from the identity
  provider. The chart's reconciler would fight with the SSO
  plugin's writes.
* **You don't trust the Secret as source of truth.** Some teams
  prefer wp-admin as the canonical credential store. Opting out
  means manual edits via wp-admin are not overwritten by Pod rolls.
* **Static demo / single-shot install.** No rotation expected,
  saves the per-Pod-start initContainer cost (\~1-3s).

## What's not in the platform

* **Auto-suppression of WP "Password Changed" emails when SMTP is on.**
  When `smtp.enabled=true` and `wp user update` writes, WP fires its
  built-in notification email. That's correct WP behaviour and most
  operators want to see rotation in their inbox. Sites that want this
  suppressed globally need a one-line `wp_mail` filter mu-plugin
  (`add_filter('send_password_change_email', '__return_false')`); not
  shipped in `mu-plugin` because of conflicting opinions.
* **Sync of arbitrary user fields.** The initContainer reconciles
  `user_pass`, `user_email`, and `user_login`. Display name, roles,
  and metadata are not synced — those live in WP and stay there.
* **Multi-user reconciliation.** One admin user per site, resolved by
  email-then-username. Multi-admin orgs run their own provisioning
  (SSO plugin or `wp user create` in build-time scripts).

## Related

* [`syncAdminCredentials` value](/components/charts#values-reference) — chart values reference
* [Reloader install + annotations](/operations/email#auto-rolling-on-secret-change-reloader) — platform-wide setup
