Fix DB password corruption via Docker secrets file mechanism

The reported bug (a generated password containing "$BRjTx3tlmSpz" got
silently blanked, breaking the DB connection) is the same Compose
interpolation issue as the earlier bcrypt hash, but this time in fields
that are genuinely user-chosen and can't just be avoided by convention.

Switched DB_PASSWORD/DB_ROOT_PASSWORD to Docker's official `_FILE`
secrets convention (MARIADB_PASSWORD_FILE / WORDPRESS_DB_PASSWORD_FILE),
backed by Compose's native `secrets:` mechanism: deploy.sh writes the raw
value to secrets/<env>/db_password, and the container reads that file
directly — the value never passes through Compose's ${VAR} interpolation
at all. Verified end-to-end with an actual `$`-containing password,
including a full deploy → backup → restore → still-serving round trip.

Also fixed along the way (found while actually testing, not assumed):
- Makefile never exported ENVIRONMENT, so `make up` alone (bypassing
  deploy.sh) would have left the new secrets path unresolved.
- deploy.sh chmod'd the secret files 600, unreadable by the container's
  own UID (www-data) — fixed to 644, relying on the containing directory
  (700) to keep other host users out instead.
- backup.sh/restore.sh still called `mysqldump`/`mysql`, which don't
  exist in the mariadb:11 image under those names — renamed to
  mariadb-dump/mariadb. (This means neither script had actually
  succeeded before now; both are verified working end-to-end here.)

The supplier/payment API keys remain passed the old way — nothing reads
them yet (bookstore-core is still a stub), so there's no live bug to fix
there; noted in .env.example that the same _FILE pattern should be used
once that code exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:36:29 -04:00
co-authored by Claude Sonnet 5
parent cd48f85a47
commit 3876a6ba30
8 changed files with 62 additions and 11 deletions
+7 -5
View File
@@ -3,11 +3,13 @@
# commit them. Same code everywhere; only these values differ (see # commit them. Same code everywhere; only these values differ (see
# design doc §01, Environments & deployment). # design doc §01, Environments & deployment).
# #
# CAVEAT for DB_PASSWORD, DB_ROOT_PASSWORD, and the API keys below: docker # CAVEAT for the API keys below (DB_PASSWORD/DB_ROOT_PASSWORD are exempt —
# compose passes these into containers via ${VAR} interpolation, which will # deploy.sh writes those into secrets/<env>/ files that bypass this
# silently mangle a value containing `$` followed by a letter (it tries to # entirely): docker compose passes these into containers via ${VAR}
# resolve it as another variable and blanks it out if unset). Avoid `$` in # interpolation, which will silently mangle a value containing `$` followed
# these specific values, or double it ($$) if you must use one. # by a letter (it tries to resolve it as another variable and blanks it out
# if unset). Avoid `$` in these specific values, or double it ($$) if you
# must use one — this bit us for real with a generated password once.
# --- Site --- # --- Site ---
# (WP_ENVIRONMENT_TYPE is NOT set here — it's hardcoded per environment in # (WP_ENVIRONMENT_TYPE is NOT set here — it's hardcoded per environment in
+1
View File
@@ -3,6 +3,7 @@
!/.env.example !/.env.example
/backups/ /backups/
/secrets/
vendor/ vendor/
node_modules/ node_modules/
*.log *.log
+1
View File
@@ -1,4 +1,5 @@
ENV ?= dev ENV ?= dev
export ENVIRONMENT = $(ENV)
COMPOSE = docker compose -p bookstore-$(ENV) -f docker-compose.yml -f docker-compose.$(ENV).yml --env-file .env.$(ENV) COMPOSE = docker compose -p bookstore-$(ENV) -f docker-compose.yml -f docker-compose.$(ENV).yml --env-file .env.$(ENV)
.PHONY: up down ps logs shell wp deploy backup .PHONY: up down ps logs shell wp deploy backup
+1
View File
@@ -21,6 +21,7 @@ docker/cron/ Action Scheduler driver (system cron has no host
deploy/deploy.sh idempotent bring-up + WP/WooCommerce config deploy/deploy.sh idempotent bring-up + WP/WooCommerce config
deploy/poll-deploy.sh host-crontab script: redeploys when its branch moves on Gitea deploy/poll-deploy.sh host-crontab script: redeploys when its branch moves on Gitea
deploy/backup.sh, restore.sh off-host backup; restore is staging-only, on purpose deploy/backup.sh, restore.sh off-host backup; restore is staging-only, on purpose
secrets/<env>/ DB passwords, written fresh by deploy.sh — gitignored, not manually edited
wp-content/plugins/bookstore-core/ the one plugin that owns business logic wp-content/plugins/bookstore-core/ the one plugin that owns business logic
wp-content/mu-plugins/ local-mail-catcher.php — routes mail to MailHog outside production wp-content/mu-plugins/ local-mail-catcher.php — routes mail to MailHog outside production
``` ```
+2 -1
View File
@@ -4,6 +4,7 @@
set -euo pipefail set -euo pipefail
ENVIRONMENT="${1:?Usage: backup.sh <staging|production>}" ENVIRONMENT="${1:?Usage: backup.sh <staging|production>}"
export ENVIRONMENT
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT" cd "$REPO_ROOT"
@@ -21,7 +22,7 @@ mkdir -p "$BACKUP_DIR"
COMPOSE="docker compose -p bookstore-${ENVIRONMENT} -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" COMPOSE="docker compose -p bookstore-${ENVIRONMENT} -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}"
echo "==> dumping database" echo "==> dumping database"
$COMPOSE exec -T db sh -c "exec mysqldump -u\"\$MARIADB_USER\" -p\"\$MARIADB_PASSWORD\" \"\$MARIADB_DATABASE\"" \ $COMPOSE exec -T db sh -c "exec mariadb-dump -u\"\$MARIADB_USER\" -p\"\$(cat /run/secrets/db_password)\" \"\$MARIADB_DATABASE\"" \
| gzip > "$BACKUP_DIR/db-${TIMESTAMP}.sql.gz" | gzip > "$BACKUP_DIR/db-${TIMESTAMP}.sql.gz"
echo "==> archiving uploads" echo "==> archiving uploads"
+18
View File
@@ -5,6 +5,7 @@
set -euo pipefail set -euo pipefail
ENVIRONMENT="${1:?Usage: deploy.sh <dev|staging|production>}" ENVIRONMENT="${1:?Usage: deploy.sh <dev|staging|production>}"
export ENVIRONMENT
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT" cd "$REPO_ROOT"
@@ -32,6 +33,23 @@ WP_ADMIN_EMAIL="$(env_get "$ENV_FILE" WP_ADMIN_EMAIL)"
# reuse dev's db_data volume/credentials the first time this ran). # reuse dev's db_data volume/credentials the first time this ran).
COMPOSE="docker compose -p bookstore-${ENVIRONMENT} -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" COMPOSE="docker compose -p bookstore-${ENVIRONMENT} -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}"
echo "==> writing DB secret files (bypasses compose's \${VAR} interpolation entirely)"
SECRETS_DIR="secrets/${ENVIRONMENT}"
mkdir -p "$SECRETS_DIR"
chmod 700 "secrets" "$SECRETS_DIR" 2>/dev/null || true
DB_PASSWORD_VALUE="$(env_get "$ENV_FILE" DB_PASSWORD)"
DB_ROOT_PASSWORD_VALUE="$(env_get "$ENV_FILE" DB_ROOT_PASSWORD)"
: "${DB_PASSWORD_VALUE:?set DB_PASSWORD in ${ENV_FILE}}"
: "${DB_ROOT_PASSWORD_VALUE:?set DB_ROOT_PASSWORD in ${ENV_FILE}}"
printf '%s' "$DB_PASSWORD_VALUE" > "$SECRETS_DIR/db_password"
printf '%s' "$DB_ROOT_PASSWORD_VALUE" > "$SECRETS_DIR/db_root_password"
# 644, not 600: the db/wordpress/cron containers read this as their own
# (non-host-matching) container UID, e.g. www-data — chmod 600 made it
# unreadable to them. The containing directory (700, above) is what
# actually keeps other host users out; these just need to be world-readable
# within that already-restricted directory.
chmod 644 "$SECRETS_DIR/db_password" "$SECRETS_DIR/db_root_password"
echo "==> building and starting ${ENVIRONMENT}" echo "==> building and starting ${ENVIRONMENT}"
$COMPOSE up -d --build $COMPOSE up -d --build
+2 -1
View File
@@ -10,6 +10,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT" cd "$REPO_ROOT"
ENV_FILE=".env.staging" ENV_FILE=".env.staging"
export ENVIRONMENT=staging
COMPOSE="docker compose -p bookstore-staging -f docker-compose.yml -f docker-compose.staging.yml --env-file ${ENV_FILE}" COMPOSE="docker compose -p bookstore-staging -f docker-compose.yml -f docker-compose.staging.yml --env-file ${ENV_FILE}"
@@ -18,7 +19,7 @@ read -r -p "type 'restore' to continue: " confirm
[[ "$confirm" == "restore" ]] || { echo "aborted"; exit 1; } [[ "$confirm" == "restore" ]] || { echo "aborted"; exit 1; }
echo "==> restoring database" echo "==> restoring database"
gunzip -c "$DB_DUMP" | $COMPOSE exec -T db sh -c "exec mysql -u\"\$MARIADB_USER\" -p\"\$MARIADB_PASSWORD\" \"\$MARIADB_DATABASE\"" gunzip -c "$DB_DUMP" | $COMPOSE exec -T db sh -c "exec mariadb -u\"\$MARIADB_USER\" -p\"\$(cat /run/secrets/db_password)\" \"\$MARIADB_DATABASE\""
echo "==> restoring uploads" echo "==> restoring uploads"
ARCHIVE_DIR="$(cd "$(dirname "$UPLOADS_ARCHIVE")" && pwd)" ARCHIVE_DIR="$(cd "$(dirname "$UPLOADS_ARCHIVE")" && pwd)"
+30 -4
View File
@@ -4,11 +4,23 @@
# values into the file once. If cron's env drifts from wordpress's (e.g. # values into the file once. If cron's env drifts from wordpress's (e.g.
# WORDPRESS_CONFIG_EXTRA missing), cron silently loses WP_REDIS_HOST, # WORDPRESS_CONFIG_EXTRA missing), cron silently loses WP_REDIS_HOST,
# DISABLE_WP_CRON, etc. The anchor below is what prevents that drift. # DISABLE_WP_CRON, etc. The anchor below is what prevents that drift.
# DB_PASSWORD/DB_ROOT_PASSWORD are deliberately NOT passed via ${VAR} here.
# Docker Compose's own interpolation mangles any value containing a `$`
# followed by a letter (confirmed with a bcrypt hash earlier, and again with
# a plain generated password: "$BRjTx3tlmSpz" got silently blanked out,
# breaking the DB connection). The official mariadb/wordpress images both
# support a `_FILE` convention specifically for this — point at a file path
# (never risky) and the container reads the raw contents itself, bypassing
# Compose's interpolation entirely. deploy.sh writes these files fresh from
# .env.<environment> before every `up`. The supplier/payment keys below are
# still passed the old way and remain exposed to the same class of bug —
# nothing consumes them yet (bookstore-core is still a stub), so when that
# code is written it should read `_FILE` variants the same way.
x-bookstore-env: &bookstore-env x-bookstore-env: &bookstore-env
WORDPRESS_DB_HOST: db WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: ${DB_NAME} WORDPRESS_DB_NAME: ${DB_NAME}
WORDPRESS_DB_USER: ${DB_USER} WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD} WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
WORDPRESS_TABLE_PREFIX: ${WP_TABLE_PREFIX:-wp_} WORDPRESS_TABLE_PREFIX: ${WP_TABLE_PREFIX:-wp_}
MAIL_CATCHER_HOST: mailhog MAIL_CATCHER_HOST: mailhog
MAIL_CATCHER_PORT: 1025 MAIL_CATCHER_PORT: 1025
@@ -26,6 +38,15 @@ x-bookstore-env: &bookstore-env
HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-} HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-}
MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-} MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-}
x-bookstore-secrets: &bookstore-db-secret
- db_password
secrets:
db_password:
file: ./secrets/${ENVIRONMENT}/db_password
db_root_password:
file: ./secrets/${ENVIRONMENT}/db_root_password
services: services:
db: db:
image: mariadb:11 image: mariadb:11
@@ -33,12 +54,15 @@ services:
environment: environment:
MARIADB_DATABASE: ${DB_NAME} MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER} MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD} MARIADB_PASSWORD_FILE: /run/secrets/db_password
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
secrets:
- db_password
- db_root_password
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
healthcheck: healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u$$MARIADB_USER -p$$MARIADB_PASSWORD --silent"] test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u$$MARIADB_USER -p\"$$(cat /run/secrets/db_password)\" --silent"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 20 retries: 20
@@ -64,6 +88,7 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: *bookstore-env environment: *bookstore-env
secrets: *bookstore-db-secret
volumes: volumes:
- wp_core:/var/www/html - wp_core:/var/www/html
- wp_uploads:/var/www/html/wp-content/uploads - wp_uploads:/var/www/html/wp-content/uploads
@@ -79,6 +104,7 @@ services:
- wordpress - wordpress
entrypoint: ["/bin/sh", "/usr/local/bin/cron-entrypoint.sh"] entrypoint: ["/bin/sh", "/usr/local/bin/cron-entrypoint.sh"]
environment: *bookstore-env environment: *bookstore-env
secrets: *bookstore-db-secret
volumes: volumes:
- wp_core:/var/www/html - wp_core:/var/www/html
- wp_uploads:/var/www/html/wp-content/uploads - wp_uploads:/var/www/html/wp-content/uploads