From 6c1dc7d45ffc16367a09172c5a05fd2aeffa5142 Mon Sep 17 00:00:00 2001 From: Matt Mabrey Date: Tue, 4 Aug 2026 18:44:34 -0700 Subject: [PATCH 1/2] Add entrypoint fallback for missing bind-mounted scripts - scripts/entrypoint.sh (new): prefers the live bind-mounted backup-cron.sh, but falls back to a copy baked into the image at build time if the mount is missing. If neither exists, it stays up and idle (instead of crash-looping) so the container remains reachable via console/exec for diagnosis. - scripts/backup-cron.sh (updated): resolves db-backup.sh the same live-or-fallback way, re-checked on every loop iteration, so if the bind mount comes back healthy later, this container picks up the live scripts on its next backup run with no restart needed. - scripts/backup.Dockerfile (updated): bakes all three scripts into the image under /app/scripts-default/ as the fallback, and sets the new wrapper as ENTRYPOINT. --- scripts/backup-cron.sh | 27 ++++++++++++++++++++---- scripts/backup.Dockerfile | 18 ++++++++++++++-- scripts/entrypoint.sh | 43 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 scripts/entrypoint.sh diff --git a/scripts/backup-cron.sh b/scripts/backup-cron.sh index 03496ed..32edeb4 100644 --- a/scripts/backup-cron.sh +++ b/scripts/backup-cron.sh @@ -1,9 +1,27 @@ #!/bin/sh -# Entry point for the `backup` sidecar container. Runs db-backup.sh on a fixed -# interval (default: daily). Kept deliberately simple — a sleep loop instead of a -# cron daemon — so it works in a bare postgres:16-alpine image. +# Entry point for the `backup` sidecar container's periodic loop. Runs +# db-backup.sh on a fixed interval (default: daily) -- a sleep loop instead of +# a cron daemon, kept deliberately simple so it works in a bare +# postgres:16-alpine image. +# +# Resolves db-backup.sh the same way entrypoint.sh resolves this file: prefer +# the live bind-mounted copy at /scripts (so edits don't need a rebuild), fall +# back to the copy baked into the image at build time if the mount is +# missing, empty, or stale. Resolving fresh on every loop iteration also means +# that if the mount comes back healthy later (e.g. someone fixes the host +# directory) this container picks it up on the very next run, with no +# restart needed. set -eu +resolve() { + # $1 = script filename, e.g. db-backup.sh + if [ -f "/scripts/$1" ]; then + echo "/scripts/$1" + else + echo "/app/scripts-default/$1" + fi +} + INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}" @@ -11,6 +29,7 @@ echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, # immediate restore point instead of waiting a whole interval. sleep 20 while true; do - sh /scripts/db-backup.sh || echo "[backup] run failed; will retry next interval" >&2 + DB_BACKUP="$(resolve db-backup.sh)" + sh "$DB_BACKUP" || echo "[backup] run failed; will retry next interval" >&2 sleep "$INTERVAL" done diff --git a/scripts/backup.Dockerfile b/scripts/backup.Dockerfile index 641ff9a..da8af38 100644 --- a/scripts/backup.Dockerfile +++ b/scripts/backup.Dockerfile @@ -1,5 +1,19 @@ # Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for -# at-rest encryption of dumps. The scripts themselves are bind-mounted at runtime -# (see the `backup` service in docker-compose.yml), so they're not COPYed here. +# at-rest encryption of dumps. +# +# The scripts are bind-mounted live at runtime (see the `backup` service in +# docker-compose.yml) so they can be edited without a rebuild -- but they're +# ALSO baked in here as a fallback default under /app/scripts-default/. +# entrypoint.sh prefers the live mount and only falls back to this baked-in +# copy if the mount is missing, empty, or stale. That fallback is what keeps +# a broken bind mount from crash-looping the container into an unreachable +# state (see entrypoint.sh for the full story). FROM postgres:16-alpine RUN apk add --no-cache openssl + +COPY scripts/backup-cron.sh scripts/db-backup.sh scripts/db-restore.sh /app/scripts-default/ +COPY scripts/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh /app/scripts-default/*.sh + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD [] diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..f85f283 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# Entrypoint for the `backup` sidecar. Prefers the live, bind-mounted copy of +# backup-cron.sh at /scripts (so it can be edited without a rebuild), and +# falls back to the copy baked into this image at build time if that bind +# mount is missing, empty, or stale. +# +# Why this exists: the previous entrypoint ran `/bin/sh /scripts/backup-cron.sh` +# directly. If that file wasn't there -- e.g. because the host directory +# backing the ./scripts bind mount hadn't been populated by whatever deploy +# process manages this stack -- the container failed instantly, and +# `restart: unless-stopped` retried in a tight crash loop forever: fast enough +# that the container was never "running" long enough for `docker exec` or +# Portainer's console to attach. That made the failure itself undiagnosable +# from inside the container -- you could only ever see it in the logs, and +# only by getting lucky with timing. This wrapper guarantees something always +# runs, and that the container always stays reachable, even in the worst case. +set -u + +LIVE="/scripts/backup-cron.sh" +FALLBACK="/app/scripts-default/backup-cron.sh" + +if [ -f "$LIVE" ]; then + echo "[entrypoint] using live scripts from /scripts (bind mount present)" + exec /bin/sh "$LIVE" +fi + +echo "[entrypoint] WARNING: $LIVE not found." >&2 +echo "[entrypoint] The ./scripts bind mount is missing, empty, or stale on the host." >&2 +echo "[entrypoint] Check the directory backing that mount (see docker-compose.yml)." >&2 + +if [ -f "$FALLBACK" ]; then + echo "[entrypoint] Falling back to the scripts baked into this image at build time." >&2 + echo "[entrypoint] Backups will still run, on whatever version was current when this" >&2 + echo "[entrypoint] image was last built -- not any newer live edits to ./scripts." >&2 + exec /bin/sh "$FALLBACK" +fi + +echo "[entrypoint] FATAL: no backup-cron.sh in the bind mount or the image." >&2 +echo "[entrypoint] Staying up (idle) instead of crash-looping, so this container" >&2 +echo "[entrypoint] can still be reached via 'docker exec' / the Portainer console." >&2 +while true; do + sleep 3600 +done -- 2.49.1 From 26f4a9242a80ce3f2cb855985f7fc1f3f133f9e5 Mon Sep 17 00:00:00 2001 From: Matt Mabrey Date: Tue, 4 Aug 2026 18:51:08 -0700 Subject: [PATCH 2/2] Entrypoint static variable removed to allow validation of scripting files before startup --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5f1e9f2..05e3df7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,7 +82,6 @@ services: volumes: - ./scripts:/scripts:ro - ./backups:/backups - entrypoint: ["/bin/sh", "/scripts/backup-cron.sh"] restart: unless-stopped depends_on: db: -- 2.49.1