#!/bin/bash set -e : "${POSTGRES_USER:=marquee}" : "${POSTGRES_DB:=marquee}" if [ -z "$POSTGRES_PASSWORD" ]; then echo "[entrypoint] POSTGRES_PASSWORD is not set. Refusing to start." >&2 exit 1 fi PG_BIN=$(dirname "$(find /usr/lib/postgresql -maxdepth 3 -name postgres | head -1)") PGDATA=/var/lib/postgresql/data # First run against an empty (or freshly mounted, empty) data volume — # initialize the cluster. On every later start this directory already has # a PG_VERSION file and initdb is skipped. if [ ! -s "$PGDATA/PG_VERSION" ]; then echo "[entrypoint] Initializing Postgres data directory at $PGDATA..." mkdir -p "$PGDATA" chown -R postgres:postgres "$PGDATA" chmod 0700 "$PGDATA" su postgres -c "$PG_BIN/initdb -D $PGDATA --auth-local=peer --auth-host=scram-sha-256" >/dev/null fi # Who may connect to Postgres, rewritten on every start so installs whose # data directory was initialized with --auth=trust (every one before this) # get it too. Trust let anything in the container connect as any role, # superuser included, without a password — so the app, which runs # unprivileged below, could have taken over the database and the files # Postgres owns. Now only the postgres OS user reaches the postgres role # (peer, over the unix socket — how this script manages roles), and # everything else needs a password (the app role's is re-set below on # every start, stored as SCRAM). The previous file is kept once, in case # it had been edited by hand. HBA="$PGDATA/pg_hba.conf" HBA_MARKER="# Managed by Marquee's entrypoint.sh" if ! grep -qF "$HBA_MARKER" "$HBA" 2>/dev/null; then [ -f "$HBA" ] && cp -p "$HBA" "$HBA.before-marquee" echo "[entrypoint] Requiring passwords for Postgres connections (previous pg_hba.conf kept as pg_hba.conf.before-marquee)..." fi cat > "$HBA" </dev/null 2>&1 || true } shutdown() { if [ -n "$APP_PID" ]; then echo "[entrypoint] Stopping Marquee..." kill -TERM "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true fi echo "[entrypoint] Stopping Postgres..." exit 0 } trap stop_postgres EXIT trap shutdown TERM INT # Idempotent — safe to run on every start, not just the first one, since a # role/database created on a previous run already satisfies these checks. # # The password reaches psql through a file rather than a nested shell # string: inside `su -c "..."` a quote, "$" or backtick in it would be # parsed a second time (or break the SQL), so a perfectly good password # could keep the container from starting. Only the SQL quoting rule # applies here (a single quote doubles). SQL_PASSWORD=${POSTGRES_PASSWORD//\'/\'\'} ROLE_SQL=$(mktemp) chown postgres:postgres "$ROLE_SQL" chmod 600 "$ROLE_SQL" if ! su postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$POSTGRES_USER'\"" | grep -q 1; then echo "[entrypoint] Creating role $POSTGRES_USER..." printf "CREATE ROLE \"%s\" WITH LOGIN PASSWORD '%s';\n" "$POSTGRES_USER" "$SQL_PASSWORD" > "$ROLE_SQL" else # Password may have changed since the role was first created (e.g. the # container was recreated with a different POSTGRES_PASSWORD) — keep # Postgres in sync with whatever's currently configured, since that's # also what DATABASE_URL below will be built from. printf "ALTER ROLE \"%s\" WITH PASSWORD '%s';\n" "$POSTGRES_USER" "$SQL_PASSWORD" > "$ROLE_SQL" fi su postgres -c "psql -q -f '$ROLE_SQL'" rm -f "$ROLE_SQL" if ! su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'\"" | grep -q 1; then echo "[entrypoint] Creating database $POSTGRES_DB..." su postgres -c "createdb -O \"$POSTGRES_USER\" \"$POSTGRES_DB\"" fi # Percent-encoded, because a password straight out of `openssl rand -base64` # has a "/" in it half the time, and postgres-js refuses the whole URL as # invalid when one turns up in the userinfo. URL_PASSWORD=$(node -e 'process.stdout.write(encodeURIComponent(process.env.POSTGRES_PASSWORD))') export DATABASE_URL="postgres://$POSTGRES_USER:$URL_PASSWORD@localhost:5432/$POSTGRES_DB" # The rolling log file (Settings › Logs, MARQUEE_LOG_DIR): a folder mounted # from the host arrives owned by whoever made it, so hand it to the app. if [ -n "$MARQUEE_LOG_DIR" ]; then mkdir -p "$MARQUEE_LOG_DIR" chown -R node:node "$MARQUEE_LOG_DIR" 2>/dev/null || echo "[entrypoint] Couldn't take ownership of $MARQUEE_LOG_DIR; the log may not be saved there." >&2 fi # Everything from here on runs as the unprivileged `node` user, not root: # the app never needs root, and shouldn't hold it if it's ever compromised. # What it writes at runtime (.next's caches) is owned by node in the image. # A copy of the database before an update changes it, so a migration that # goes wrong can be undone: only when this image has migrations the database # hasn't had yet (the newest one in the journal is newer than the last one # drizzle recorded — the same test drizzle itself uses), and not on a first # start, when there's nothing to keep. Stored in the data volume next to the # database, the newest few kept. Restore one with: # pg_restore --clean --if-exists -d "$POSTGRES_DB" BACKUP_DIR="$PGDATA/marquee-backups" BACKUPS_KEPT=3 LATEST_MIGRATION=$(node -e 'const j=require("/app/lib/db/migrations/meta/_journal.json"); process.stdout.write(String(Math.max(...j.entries.map((e) => e.when))))') # (Two queries: Postgres resolves the table name before it runs anything, # so a single one would fail outright on a database that doesn't have it.) APPLIED_MIGRATION= if [ -n "$(su postgres -c "psql -d \"$POSTGRES_DB\" -tAc \"SELECT to_regclass('drizzle.__drizzle_migrations')\"")" ]; then APPLIED_MIGRATION=$(su postgres -c "psql -d \"$POSTGRES_DB\" -tAc \"SELECT COALESCE(MAX(created_at), 0) FROM drizzle.__drizzle_migrations\"") fi if [ -n "$APPLIED_MIGRATION" ] && [ "$APPLIED_MIGRATION" -lt "$LATEST_MIGRATION" ]; then mkdir -p "$BACKUP_DIR" chown postgres:postgres "$BACKUP_DIR" chmod 0700 "$BACKUP_DIR" VERSION=$(node -p 'require("/app/package.json").version') BACKUP="$BACKUP_DIR/before-$VERSION-$(date -u +%Y%m%d-%H%M%S).dump" echo "[entrypoint] New database migrations to run; backing the database up to $BACKUP first..." if ! su postgres -c "pg_dump -Fc -d \"$POSTGRES_DB\" -f \"$BACKUP\""; then rm -f "$BACKUP" echo "[entrypoint] Couldn't back the database up (is the disk full?), so not migrating it. Refusing to start." >&2 exit 1 fi # Only the newest few: each is a full copy of the database. ls -1t "$BACKUP_DIR"/before-*.dump 2>/dev/null | tail -n +$((BACKUPS_KEPT + 1)) | xargs -r rm -f fi echo "[entrypoint] Running database migrations..." if ! gosu node npx drizzle-kit migrate; then echo "[entrypoint] Database migration failed. Refusing to start.${BACKUP:+ The database as it was before is in $BACKUP.}" >&2 exit 1 fi echo "[entrypoint] Starting Marquee..." gosu node "$@" & APP_PID=$! set +e wait "$APP_PID" STATUS=$? # Only reached when the app exited on its own (a signal runs shutdown above # and exits there); the EXIT trap still shuts Postgres down cleanly. exit "$STATUS"