feat(ops): script to rotate PostgreSQL password safely

Add rotate-postgres-password.sh with optional app role, document the
procedure, and stop defaulting production POSTGRES_PASSWORD to postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 15:09:15 +02:00
parent 3d8a505bd9
commit b9ce853059
8 changed files with 236 additions and 186 deletions
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Rotate PostgreSQL password on a running Docker Compose stack (existing volume safe).
#
# The Postgres image only applies POSTGRES_PASSWORD on first init; for existing data
# you must ALTER USER inside the running database, then update .env and restart backend.
#
# Usage (on server in repo root, with backup/snapshot taken):
# ./scripts/rotate-postgres-password.sh
# ./scripts/rotate-postgres-password.sh --app-user daagbok # optional: dedicated app role
#
# Writes the new credentials once to .postgres-credentials.<timestamp> (mode 600).
set -euo pipefail
ENV_FILE="${ENV_FILE:-.env}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
DB_CONTAINER="${DB_CONTAINER:-daagbox-prod-db}"
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
CREATE_APP_USER=""
APP_USER_NAME="daagbok"
while [ $# -gt 0 ]; do
case "$1" in
--app-user)
CREATE_APP_USER=1
APP_USER_NAME="${2:-daagbok}"
shift 2
;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
OLD_PASSWORD="${POSTGRES_PASSWORD:-}"
if [ -z "$OLD_PASSWORD" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
NEW_PASSWORD="$(openssl rand -hex 24)"
NEW_APP_PASSWORD=""
if [ -n "$CREATE_APP_USER" ]; then
NEW_APP_PASSWORD="$(openssl rand -hex 24)"
fi
BACKUP_ENV="${ENV_FILE}.bak.pg-rotate.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$BACKUP_ENV"
echo "Backed up $ENV_FILE$BACKUP_ENV"
echo "Rotating password for PostgreSQL role: $POSTGRES_USER (database: $POSTGRES_DB)"
# Escape single quotes for SQL string literals
sql_escape() {
printf "%s" "$1" | sed "s/'/''/g"
}
NEW_PW_SQL="$(sql_escape "$NEW_PASSWORD")"
export PGPASSWORD="$OLD_PASSWORD"
if ! docker exec -e PGPASSWORD="$OLD_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 \
-c "ALTER USER \"${POSTGRES_USER}\" WITH PASSWORD '${NEW_PW_SQL}';" >/dev/null; then
echo "Error: ALTER USER failed. Is POSTGRES_PASSWORD in .env still correct?" >&2
exit 1
fi
unset PGPASSWORD
TARGET_USER="$POSTGRES_USER"
TARGET_PASSWORD="$NEW_PASSWORD"
if [ -n "$CREATE_APP_USER" ]; then
APP_PW_SQL="$(sql_escape "$NEW_APP_PASSWORD")"
export PGPASSWORD="$NEW_PASSWORD"
docker exec -e PGPASSWORD="$NEW_PASSWORD" "$DB_CONTAINER" psql -U postgres -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${APP_USER_NAME}') THEN
CREATE ROLE ${APP_USER_NAME} LOGIN PASSWORD '${APP_PW_SQL}';
ELSE
ALTER ROLE ${APP_USER_NAME} WITH LOGIN PASSWORD '${APP_PW_SQL}';
END IF;
END
\$\$;
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO ${APP_USER_NAME};
GRANT USAGE, CREATE ON SCHEMA public TO ${APP_USER_NAME};
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${APP_USER_NAME};
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${APP_USER_NAME};
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER_NAME};
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER_NAME};
SQL
unset PGPASSWORD
TARGET_USER="$APP_USER_NAME"
TARGET_PASSWORD="$NEW_APP_PASSWORD"
echo "Created/updated application role: $APP_USER_NAME (postgres superuser password also rotated)"
fi
# Update .env without exposing values in process list longer than necessary
python3 - "$ENV_FILE" "$TARGET_USER" "$TARGET_PASSWORD" "$NEW_PASSWORD" "$CREATE_APP_USER" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
target_user = sys.argv[2]
target_password = sys.argv[3]
postgres_password = sys.argv[4]
use_app_user = sys.argv[5] == "1"
text = path.read_text(encoding="utf-8")
def set_var(name: str, value: str, content: str) -> str:
pattern = rf"^{re.escape(name)}=.*$"
line = f"{name}={value}"
if re.search(pattern, content, flags=re.M):
return re.sub(pattern, line, content, count=1, flags=re.M)
return content.rstrip() + "\n" + line + "\n"
text = set_var("POSTGRES_USER", target_user, text)
text = set_var("POSTGRES_PASSWORD", target_password, text)
text = set_var("POSTGRES_DB", "daagbox", text) if "POSTGRES_DB=" not in text else text
if use_app_user:
text = set_var("POSTGRES_ADMIN_PASSWORD", postgres_password, text)
path.write_text(text, encoding="utf-8")
PY
CREDS_FILE=".postgres-credentials.$(date +%Y%m%d-%H%M%S)"
umask 077
{
echo "# Generated $(date -Iseconds) — store in password manager, then delete this file."
echo "POSTGRES_USER=$TARGET_USER"
echo "POSTGRES_PASSWORD=$TARGET_PASSWORD"
echo "POSTGRES_DB=$POSTGRES_DB"
if [ -n "$CREATE_APP_USER" ]; then
echo "POSTGRES_ADMIN_USER=postgres"
echo "POSTGRES_ADMIN_PASSWORD=$NEW_PASSWORD"
fi
} > "$CREDS_FILE"
chmod 600 "$CREDS_FILE"
echo "Credentials written to $CREDS_FILE (chmod 600)"
echo "Restarting backend to pick up DATABASE_URL..."
docker compose -f "$COMPOSE_FILE" up -d backend
echo "Waiting for backend health..."
for _ in $(seq 1 45); do
status="$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null || echo missing)"
if [ "$status" = healthy ]; then
break
fi
sleep 1
done
export PGPASSWORD="$TARGET_PASSWORD"
docker exec -e PGPASSWORD="$TARGET_PASSWORD" "$DB_CONTAINER" \
psql -U "$TARGET_USER" -d "$POSTGRES_DB" -tAc 'SELECT count(*) FROM "User";' >/dev/null
unset PGPASSWORD
if curl -sf http://127.0.0.1/api/health | grep -q '"status":"ok"'; then
echo "OK: /api/health and DB connection verified."
else
echo "Warning: health check failed — see: docker compose logs backend" >&2
exit 1
fi
echo "Done. Remove $CREDS_FILE after saving credentials securely."
+5 -2
View File
@@ -29,8 +29,11 @@ echo "Patching $ENV_FILE for Sprint 1..."
# Match running container (docker exec daagbox-prod-db: USER=postgres DB=daagbox)
ensure_var POSTGRES_USER "postgres"
ensure_var POSTGRES_DB "daagbox"
# Default from legacy docker-compose.yml; change only if you use a different DB password
ensure_var POSTGRES_PASSWORD "postgres"
if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$" "$ENV_FILE"; then
echo " skip POSTGRES_PASSWORD (set manually or run scripts/rotate-postgres-password.sh)"
else
echo " keep POSTGRES_PASSWORD (already set)"
fi
# NPM on 172.16.10.10 → app on this host
ensure_var TRUST_PROXY "172.16.10.10"