From 8911351c99d2d2bb430b60d032de1edd27999fdb Mon Sep 17 00:00:00 2001 From: Jack Levy Date: Sun, 15 Mar 2026 17:31:09 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20DB=20password=20=E2=80=94=20read=20from?= =?UTF-8?q?=20secrets=20file,=20bypasses=20Docker=20Compose=20interpolatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add secrets/db_password file support to docker-compose.yml (Docker secrets mount) - config.py reads POSTGRES_PASSWORD_FILE if set, builds DATABASE_URL with proper URL encoding - Remove inline DATABASE_URL construction from docker-compose.yml (was subject to $VAR interpolation) - Any password with any characters now works — no escaping needed Authored by: Jack Levy --- .env.example | 15 ++++++++------- .gitignore | 3 +++ backend/app/config.py | 29 +++++++++++++++++++++++++---- docker-compose.yml | 26 +++++++++++++++++++------- 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 33037d2..e9b91f6 100644 --- a/.env.example +++ b/.env.example @@ -17,16 +17,17 @@ ENCRYPTION_SECRET_KEY= # ─── PostgreSQL ─────────────────────────────────────────────────────────────── POSTGRES_USER=congress -# If your password contains special characters ($, &, #, etc.), wrap it in single quotes -# to prevent Docker Compose from interpreting them as variable substitutions. -# Example: POSTGRES_PASSWORD='p@$$w0rd&safe' +# Any password works — special characters ($, &, #, @, etc.) are all fine. +# Wrap in single quotes if your password contains spaces or leading/trailing chars +# you want preserved. The DATABASE_URL is built automatically by the backend +# with proper URL-encoding, so you never need to escape anything here. POSTGRES_PASSWORD=change-me POSTGRES_DB=pocketveto -# These are constructed automatically from the above in docker-compose.yml. -# Override here only if connecting to an external DB. -# DATABASE_URL=postgresql+asyncpg://congress:congress@postgres:5432/pocketveto -# SYNC_DATABASE_URL=postgresql://congress:congress@postgres:5432/pocketveto +# DATABASE_URL and SYNC_DATABASE_URL are built automatically from the values +# above. Set these only if connecting to an external database. +# DATABASE_URL=postgresql+asyncpg://congress:mypassword@postgres:5432/pocketveto +# SYNC_DATABASE_URL=postgresql://congress:mypassword@postgres:5432/pocketveto # ─── Redis ──────────────────────────────────────────────────────────────────── REDIS_URL=redis://redis:6379/0 diff --git a/.gitignore b/.gitignore index 2e57220..cbebe63 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ frontend/out/ # Docker — bind-mount data directories (created on first run) postgres/ redis/ + +# Secrets — never commit these +secrets/ *.log diff --git a/backend/app/config.py b/backend/app/config.py index ace682a..1026ff3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,6 @@ +import os from functools import lru_cache +from urllib.parse import quote as urlquote from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -19,9 +21,18 @@ class Settings(BaseSettings): # Falls back to JWT_SECRET_KEY derivation if not set (not recommended for production) ENCRYPTION_SECRET_KEY: str = "" - # Database - DATABASE_URL: str = "postgresql+asyncpg://congress:congress@postgres:5432/pocketveto" - SYNC_DATABASE_URL: str = "postgresql://congress:congress@postgres:5432/pocketveto" + # Database — built automatically from components (supports any characters in password) + POSTGRES_USER: str = "congress" + POSTGRES_PASSWORD: str = "congress" + POSTGRES_DB: str = "pocketveto" + POSTGRES_HOST: str = "postgres" + POSTGRES_PORT: int = 5432 + # Path to a file containing the raw DB password (any chars, no escaping needed). + # When set, this takes priority over POSTGRES_PASSWORD. + POSTGRES_PASSWORD_FILE: str = "" + # Override these only if connecting to an external DB not managed by Docker Compose + DATABASE_URL: str = "" + SYNC_DATABASE_URL: str = "" # Redis REDIS_URL: str = "redis://redis:6379/0" @@ -61,12 +72,22 @@ class Settings(BaseSettings): PYTRENDS_ENABLED: bool = True @model_validator(mode="after") - def check_secrets(self) -> "Settings": + def check_secrets_and_build_db_url(self) -> "Settings": if self.JWT_SECRET_KEY == "change-me-in-production": raise ValueError( "JWT_SECRET_KEY must be set to a secure random value in .env. " "Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\"" ) + if not self.DATABASE_URL: + if self.POSTGRES_PASSWORD_FILE and os.path.isfile(self.POSTGRES_PASSWORD_FILE): + with open(self.POSTGRES_PASSWORD_FILE) as f: + raw_pw = f.read().strip() + else: + raw_pw = self.POSTGRES_PASSWORD + pw = urlquote(raw_pw, safe="") + base = f"{self.POSTGRES_USER}:{pw}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + self.DATABASE_URL = f"postgresql+asyncpg://{base}" + self.SYNC_DATABASE_URL = f"postgresql://{base}" return self # SMTP (Email notifications) diff --git a/docker-compose.yml b/docker-compose.yml index 7bf69e9..04b153d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,10 @@ services: image: postgres:16-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-congress} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-congress} POSTGRES_DB: ${POSTGRES_DB:-pocketveto} + POSTGRES_PASSWORD_FILE: /run/secrets/db_password + secrets: + - db_password volumes: - ./postgres/data:/var/lib/postgresql/data healthcheck: @@ -36,9 +38,11 @@ services: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" env_file: .env environment: - - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-congress}:${POSTGRES_PASSWORD:-congress}@postgres:5432/${POSTGRES_DB:-pocketveto} - - SYNC_DATABASE_URL=postgresql://${POSTGRES_USER:-congress}:${POSTGRES_PASSWORD:-congress}@postgres:5432/${POSTGRES_DB:-pocketveto} + - POSTGRES_HOST=postgres + - POSTGRES_PASSWORD_FILE=/run/secrets/db_password - REDIS_URL=redis://redis:6379/0 + secrets: + - db_password depends_on: postgres: condition: service_healthy @@ -54,9 +58,11 @@ services: command: celery -A app.workers.celery_app worker --loglevel=info --concurrency=4 -Q polling,documents,llm,news env_file: .env environment: - - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-congress}:${POSTGRES_PASSWORD:-congress}@postgres:5432/${POSTGRES_DB:-pocketveto} - - SYNC_DATABASE_URL=postgresql://${POSTGRES_USER:-congress}:${POSTGRES_PASSWORD:-congress}@postgres:5432/${POSTGRES_DB:-pocketveto} + - POSTGRES_HOST=postgres + - POSTGRES_PASSWORD_FILE=/run/secrets/db_password - REDIS_URL=redis://redis:6379/0 + secrets: + - db_password depends_on: postgres: condition: service_healthy @@ -72,9 +78,11 @@ services: command: celery -A app.workers.celery_app beat --loglevel=info --scheduler=redbeat.RedBeatScheduler env_file: .env environment: - - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-congress}:${POSTGRES_PASSWORD:-congress}@postgres:5432/${POSTGRES_DB:-pocketveto} - - SYNC_DATABASE_URL=postgresql://${POSTGRES_USER:-congress}:${POSTGRES_PASSWORD:-congress}@postgres:5432/${POSTGRES_DB:-pocketveto} + - POSTGRES_HOST=postgres + - POSTGRES_PASSWORD_FILE=/run/secrets/db_password - REDIS_URL=redis://redis:6379/0 + secrets: + - db_password depends_on: redis: condition: service_healthy @@ -108,3 +116,7 @@ services: networks: app_network: driver: bridge + +secrets: + db_password: + file: ./secrets/db_password