Caddy basicauth: bcrypt hash breaks in Docker Compose env_file
Trigger
Caddy crash-loops with:
Error: loading initial config: provision http.authentication.providers.http_basic:
account 0: username and password are required
When basicauth uses {env.STATUS_BASIC_AUTH_USER} / {env.STATUS_BASIC_AUTH_HASH} in Caddyfile.
Root cause
Docker Compose env_file: performs variable substitution on values. Bcrypt hashes contain $ signs
(e.g. $2a$14$ABC...). Docker Compose expands $ABC as an undefined variable → empty string.
The hash gets garbled. Caddy sees an empty hash and reports “username and password are required”.
docker run --env-file does NOT interpolate — the discrepancy makes debugging confusing.
Confirm it
# Check Caddy status
cd /opt/p24-infra/monitoring && docker compose ps caddy
# Check logs
docker compose logs caddy --tail=5 2>&1 | grep -v 'variable is not set'If you see “username and password are required” AND warnings like:
The "SOME_VAR" variable is not set. Defaulting to a blank string.
where SOME_VAR looks like part of a bcrypt hash — this is the root cause.
Fix
Step 1 — Generate new password and hash
# On vps-i1 or any host with Docker
PASS=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20)
HASH=$(docker run --rm caddy:2.11.4-alpine caddy hash-password --plaintext "$PASS")
echo "PASS=$PASS"
echo "HASH=$HASH"Step 2 — Store in SOPS with $$ escaping
On developer workstation (PowerShell):
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# Decrypt
$lines = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
# Escape hash: replace each $ with $$
$escapedHash = $HASH.Replace('$', '$$')
# Update lines and re-encrypt
$updated = $lines | ForEach-Object {
if ($_.StartsWith('STATUS_BASIC_AUTH_HASH=')) { "STATUS_BASIC_AUTH_HASH=$escapedHash" }
elseif ($_.StartsWith('STATUS_BASIC_AUTH_PASSWORD=')) { "STATUS_BASIC_AUTH_PASSWORD=$PASS" }
else { $_ }
}
[System.IO.File]::WriteAllText(
"$PWD\secrets\monitoring-edit.env.sops",
[string]::Join("`n", $updated) + "`n",
[System.Text.UTF8Encoding]::new($false)
)
$enc = sops --encrypt --input-type dotenv --output-type dotenv secrets\monitoring-edit.env.sops
[System.IO.File]::WriteAllText(
"$PWD\secrets\monitoring.env.sops",
[string]::Join("`n", $enc) + "`n",
[System.Text.UTF8Encoding]::new($false)
)
[System.IO.File]::Delete("$PWD\secrets\monitoring-edit.env.sops")Step 3 — Deploy to server
If via secrets-sync (PR + merge → CI deploys automatically), skip to Step 4.
For immediate fix — deploy the .env directly:
# Decrypt, escape hash in the deploy file, SCP
$decrypted = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
$tempLines = $decrypted | ForEach-Object {
if ($_.StartsWith('STATUS_BASIC_AUTH_HASH=')) {
# Already stored with $$ in SOPS, but verify before SCP
$_
} else { $_ }
}
[System.IO.File]::WriteAllText("$env:TEMP\mon.env", [string]::Join("`n", $tempLines) + "`n", [System.Text.UTF8Encoding]::new($false))
scp -i C:\Users\konar\.ssh\id_ed25519 "$env:TEMP\mon.env" "root@217.154.82.162:/opt/p24-infra/monitoring/.env"
Remove-Item "$env:TEMP\mon.env"Step 4 — Restart Caddy (force-recreate required)
# --force-recreate picks up updated env vars from .env
cd /opt/p24-infra/monitoring
docker compose up -d --no-deps --force-recreate caddy
sleep 4
docker compose ps caddydocker compose restart caddy does NOT re-read the .env — must use --force-recreate.
Verify
docker compose ps caddy
# Status must be: Up N seconds (healthy)
# NOT: Restarting
docker compose logs caddy --tail=5 2>&1 | grep -v 'variable is not set'
# Must NOT contain "username and password are required"Escalation
If Caddy still fails after above:
- Check the hash format in the .env on the server:
grep STATUS_BASIC_AUTH_HASH /opt/p24-infra/monitoring/.env- Should start with
$$2a$$14$$
- Should start with
- Verify Docker Compose passes correct value:
docker run --rm --env-file /opt/p24-infra/monitoring/.env alpine printenv STATUS_BASIC_AUTH_HASH- Will show
$$2a$$14$$...(raw, no interpolation) — this is expected - What matters is what
docker composepasses, notdocker run --env-file
- Will show
- Manually test hash:
echo "p24admin:$(docker compose exec caddy caddy environ | grep STATUS_BASIC_AUTH_HASH)" | caddy hash-password --verify
Prevention
- Always escape
$as$$in.envfile values that contain$signs before Docker Compose reads them - When storing bcrypt hashes in SOPS for Docker Compose env_file deployment: escape
$→$$at SOPS store time - The secrets-sync workflow deploys SOPS values verbatim — escaping must be in SOPS, not in the workflow