The runbook opened with `ALTER USER default IDENTIFIED BY ...` and claimed the image entrypoint reads CLICKHOUSE_PASSWORD only when it initialises an empty data volume. Both claims were wrong, and following them would have failed at the first command. `SELECT name, storage FROM system.users` reports `default | users_xml`: the user is defined by a config file, and ClickHouse rejects ALTER USER against that storage. There is no in-database password to change. The password lives in /etc/clickhouse-server/users.d/default-user.xml, which the entrypoint regenerates on *every* container start -- that path is in the container filesystem, and only /var/lib/clickhouse is a volume. The file's mtime matches the container's StartedAt to the second, before and after a rotation. So the env var is the source of truth and recreating the container is what applies it -- close to the inverse of what was written. /opt/sentry's own docker-compose.override.yml header had recorded the correct mechanism during the 2026-08-19 rotation. The runbook was drafted without reading it. Noted at the top of the new Verification status section, since a runbook that was wrong once should say so. Procedure rewritten around what was actually executed against proto on 2026-08-23 and verified: back up first, generate hex on the box so the value never reaches a terminal or transcript, sed only indented KEY: value lines so the header comment is untouched, `docker compose config -q` before touching anything live, then recreate. Verification now checks that the OLD password is rejected -- read from the backup rather than retyped -- not merely that the new one works. Rollback rewritten to restore the backup, since there is no ALTER to reverse. Every shell block syntax-checked with bash -n.
7.6 KiB
Runbook: rotating the ClickHouse default password
Applies to the docker-compose deployments (proto.cairnobs.org,
demo.cairnobs.org). For Helm deployments the value is a K8s Secret
(/deploy/helm/cairnobs/templates/secrets.yaml) and the procedure is a
secret update plus a rollout restart. The users_xml mechanics in "The
trap" below apply there too — the chart's ClickHouse pod builds the same
default-user.xml from the same env var — so the shape is identical:
change the value, recreate the pod, verify. Only the sed-the-override
steps differ.
What you are rotating
Not a login gate. CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 (set in
docker-compose.yml for Phase 4's per-tenant provisioning) makes the
default user a ClickHouse superuser:
$ docker exec cairnobs-clickhouse clickhouse-client --query "SHOW GRANTS FOR default"
GRANT SHOW, SELECT, INSERT, ALTER, CREATE, DROP, ... ACCESS MANAGEMENT,
CLUSTER ON *.* TO default WITH GRANT OPTION
Read/write over every tenant's logs, plus the ability to create further
users. See /docs/security/threat-model.md for how this sits in the
wider model.
Before you start
- Confirm the blast radius is what you think it is. ClickHouse
publishes only to loopback on these boxes (
127.0.0.1:8123,127.0.0.1:9000); the only0.0.0.0listener iscairnobs-ingest:4317, the mTLS agent endpoint. Verify withdocker ps --format "{{.Names}}\t{{.Ports}}"before assuming an exposure is or isn't reachable. - The boxes do not share a password. Rotate them independently, and do not assume a value recovered from one applies to the other.
- Rotate demo before proto, matching the deploy risk order.
The trap: do NOT reach for ALTER USER
The obvious move — change the password inside ClickHouse, then update the env to match — does not work here, and fails loudly:
$ docker exec cairnobs-clickhouse clickhouse-client \
--query "SELECT name, storage FROM system.users"
default users_xml
storage = users_xml means the default user is defined by a config
file, not by SQL. ClickHouse treats that storage as read-only and
rejects ALTER USER against it. There is no in-database password to
change.
The password lives in /etc/clickhouse-server/users.d/default-user.xml,
which the official image's entrypoint regenerates from
CLICKHOUSE_PASSWORD on every container start — not only on first
init. That directory is part of the container filesystem; only
/var/lib/clickhouse (the data) is a volume, so the file is rebuilt
each time and the data survives untouched.
So the env var is the source of truth, and recreating the container is
what applies it. Verify by comparing the file's mtime against
docker inspect <container> --format '{{.State.StartedAt}}' — they
match to the second, before and after a rotation.
Procedure
Four compose services declare the credential: clickhouse,
clickhouse-migrate, api, ingest. Only three of them are
long-running — clickhouse-migrate is a one-shot that has already
exited on a running box, which is why docker ps shows three. All
four must be updated in the override file, or the next docker compose up will run the migrate step with a stale password and fail.
web, alerting, search, enterprise-auth, metadata-postgres, and
redpanda do not carry it and do not need restarting.
1. Back up the override file. It is the only copy of these credentials, and it is the rollback path.
cd /opt/sentry # or /home/john/cairnobs-demo on demo
cp -p docker-compose.override.yml \
docker-compose.override.yml.bak.$(date +%Y%m%d-%H%M%S)
2. Generate the value on the box, and never let it leave. Use hex,
not base64: this string is interpolated into the generated
default-user.xml, and base64's /, +, and = are needless risk in
XML. Generating it remotely also keeps it out of your terminal
scrollback and out of any AI-assistant transcript.
NEW="$(openssl rand -hex 24)"
3. Update all four occurrences in the override file. Match only
indented KEY: value lines, so the header comment (which mentions
CLICKHOUSE_PASSWORD in prose) is left alone:
sed -i -E "s|^( +)CLICKHOUSE_PASSWORD: .*|\1CLICKHOUSE_PASSWORD: \"${NEW}\"|" \
docker-compose.override.yml
grep -cE '^ +CLICKHOUSE_PASSWORD: ' docker-compose.override.yml # expect 4
The file is gitignored precisely because it carries real credentials; it is not in the repo, and an rsync deploy will not overwrite it unless you explicitly tell it to.
4. Validate before touching anything live.
docker compose config -q && echo OK
If this fails, restore the backup and stop.
5. Recreate the three long-running services. This is what applies
the new password — the entrypoint rewrites default-user.xml as
clickhouse comes up.
docker compose up -d clickhouse api ingest
clickhouse-migrate and the other one-shots will run and exit cleanly
as dependencies; that is expected, and is why step 3 had to update their
copy too.
6. Verify. All four must pass:
# a) the OLD password is now rejected -- read it from the backup rather
# than retyping it anywhere
OLD=$(grep -m1 -E '^ +CLICKHOUSE_PASSWORD: ' docker-compose.override.yml.bak.* \
| sed -E 's/.*: *"?([^"]*)"?$/\1/')
docker exec cairnobs-clickhouse clickhouse-client --user default \
--password "$OLD" --query "SELECT 1" && echo "FAILED: old still works"
# b) the new password is accepted
docker exec cairnobs-clickhouse clickhouse-client --user default \
--password "$NEW" --query "SELECT 1"
# c) the app can still read logs, and the count climbs on a live box
docker exec cairnobs-clickhouse clickhouse-client \
--query "SELECT count() FROM cairnobs.logs"
# d) no auth failures since the restart
docker compose logs --since 5m api ingest | grep -icE '403|auth.*fail|denied'
If (b) passes but (c) or (d) fail, the override still holds the old value somewhere — recheck every service block from step 3.
Rollback
Restore the backup from step 1 and recreate the same three services:
cp -p docker-compose.override.yml.bak.<timestamp> docker-compose.override.yml
docker compose config -q && docker compose up -d clickhouse api ingest
Nothing else has to be undone. The rotation changes no schema and no
data — /var/lib/clickhouse is a volume and is never rewritten by this
procedure — so rollback is symmetric and carries no data-loss risk.
Verification status
This procedure was executed end-to-end against proto.cairnobs.org on
2026-08-23: old password confirmed rejected, new one accepted,
default-user.xml mtime matching the new StartedAt, zero auth
failures, and the ingest row count still climbing afterwards. No
downtime beyond the recreate of clickhouse, api, and ingest.
An earlier draft of this runbook had it backwards — it opened with
ALTER USER and claimed the entrypoint only reads CLICKHOUSE_PASSWORD
at volume-init. Both were wrong, and /opt/sentry's own
docker-compose.override.yml header had already recorded the correct
mechanism during the 2026-08-19 rotation. Check that file before
trusting anything here.
Known gap
The compose path keeps the password in the container environment, where
docker inspect exposes it to anyone with Docker access — which on
these boxes is root-equivalent anyway. The Helm path already avoids this
via secretKeyRef. Closing it for compose would need a
CLICKHOUSE_PASSWORD_FILE variant in the config loaders
(api/internal/config, ingest/internal/config); only TLS_*_FILE
paths support file-sourced values today. Worth doing only if these hosts
ever gain more than single-operator access.