Files
admin/compose-bcrypt-escape-trap.md
vitya dd7c56742e docs(.wiki,.tasks): vds-kzntsv-bootstrap done — 3 phases shipped
VDS Rusonyx 160 NVMe (89.253.255.94 / vds.kzntsv.site) активирован
2026-05-20. 3 фазы за ~6 часов:

- Phase 1: harden + docker 29.5.1 + traefik v2.11 + portainer 2.21.5
- Phase 2: shared DB park (postgres 16 / mariadb 11.4 / mongo 7 /
  redis 7) via traefik raw TCP forward + self-signed TLS
- Phase 3: миграция gitea (132 repos), verdaccio (2063 pkgs), registry
  fresh install (user accepted loss old images) + Joxit GUI

Ingest 1 source + 1 entity (vds-kzntsv) + 6 concepts (traefik-tcp-
passthrough-vs-starttls / portainer-2.21-admin-password-regression /
db-tls-self-signed-via-traefik-raw-tcp / rusonyx-vps-onboarding-quirks
/ registry-gc-mount-and-modify-flag / compose-bcrypt-escape-trap).

3 follow-up  tasks: vds-gc-cron, vds-backup-rsync-kreknin, vds-ntfy-push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:39:31 +03:00

4.5 KiB
Raw Blame History

title, type, tags, sources, updated
title type tags sources updated
Docker Compose bcrypt `$` escape trap concept
docker-compose
bcrypt
password
escape
gotcha
../sources/vds-kzntsv-bootstrap-2026-05-20.md
2026-05-20

Docker Compose ест $ в bcrypt hashes

Симптом

services:
  app:
    command: --admin-password '$2y$05$abc123...'

При docker compose up Compose выдаёт warning:

warning msg="The \"r9BLFM98iCLTskjgj7Y3teOACut3Nxk\" variable is not set. Defaulting to a blank string."

И в контейнер передаётся пустая строка вместо bcrypt hash.

Root cause

Compose interpolates ${VAR} и $VAR syntax из env variables в YAML values. Bcrypt hash начинается с $2y$ или $2a$ — выглядит как $NAME шаблоны для compose'а:

  • $2y → попытка expand env var 2y → не определена → empty string
  • $05 → expand env var 05 → empty string
  • Остаток до точки/конца строки → имя var → empty
  • Точки/слэши прерывают имя var

Результат — обрезанный/пустой hash.

Решение 1 — double-escape $$$

В YAML literal compose treats $$ как escape для $. Нужно sed-replace перед записью:

BCRYPT=$(htpasswd -nbB user pass | cut -d':' -f2)
BCRYPT_ESCAPED=$(echo "$BCRYPT" | sed 's/\$/\$\$/g')
cat > docker-compose.yml <<COMPOSE
services:
  app:
    command: --admin-password '$BCRYPT_ESCAPED'
COMPOSE

docker compose config покажет $$2y$$05$$... — это нормально, в runtime expand'ится в $2y$05$....

Но осторожно с кавычками: YAML string form command: "... '<hash>'" — после tokenization shell видит literal одинарные кавычки внутри значения. Argument приходит как '$2y$05$...' (с кавычками вокруг hash) → bcrypt verify fails из-за паразитных ' char'ов.

Решение 2 — YAML list form

command:
  - "--admin-password"
  - "$$2y$$05$$abc123..."

Каждый list-element — отдельный argv element, без shell tokenization. Кавычки не утекают.

Но опять же — некоторые версии Compose (2024+) могут иметь регрессии где YAML list form не unescape'ит $$ обратно в $. Проверять через docker compose config | grep command после написания.

Решение 3 — docker run напрямую, без compose

Bypass проблему. Shell escape'ы (одинарные кавычки) предсказуемы:

BCRYPT=$(htpasswd -nbB user pass | cut -d':' -f2)
docker run -d --name app \
  -v ... \
  app/app:tag \
  --admin-password "$BCRYPT"   # bash interpolation одного слоя

Bash "$BCRYPT" expand'ится один раз. Внутри expansion'а $ уже не парсится. Container получает чистый hash.

Trade-off: лишаемся compose файла → нет declarative restart, нужно docker run --restart unless-stopped. Управляемо.

Решение 4 — env file with single var

services:
  app:
    env_file: ./bcrypt.env
    command: --admin-password ${BCRYPT}

bcrypt.env:

BCRYPT=$2y$05$abc123...

Env file читается raw, без compose interpolation. Тогда ${BCRYPT} в command expand'ится из этого env. Работает.

Но менее transparent — debugger должен смотреть в env file.

Похожие traps

Любые значения с $:

  • Postgres password secret$pass$word
  • API keys с $ (rare)
  • Cron syntax в command ($1 etc.)
  • Bash variable substitutions внутри command

Где применено

portainer/portainer-ce:2.21.5 на vds-kzntsv — финальный путь = решение 3 (docker run direct). Хотя в этой же сессии оказалось что --admin-password flag broken в Portainer 2.21+ regardless escape — см. portainer-2.21-admin-password-regression.

Ссылки