diff --git a/Dockerfile b/Dockerfile
index d4b526e..051862a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,34 +1,48 @@
# Use LinuxServer.io Duplicati base
FROM linuxserver/duplicati:2.1.0
-# Install Docker CLI, bash, python3
-RUN apt-get update && \
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+# Install Docker CLI, bash, python3, btrfs support and all the app directories
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ curl \
+ gnupg \
+ lsb-release \
bash \
python3 \
python3-pip \
- docker.io \
btrfs-progs \
- ca-certificates curl && \
- rm -rf /var/lib/apt/lists/*
+ && mkdir -p /etc/apt/keyrings \
+ && curl -fsSL "https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg" \
+ | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
+ https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") \
+ $(lsb_release -cs) stable" \
+ | tee /etc/apt/sources.list.d/docker.list > /dev/null \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends \
+ docker-ce-cli \
+ && groupadd -f docker \
+ && usermod -aG docker abc \
+ && rm -rf /var/lib/apt/lists/* \
+ && mkdir -p /usr/local/bin /config /etc/services.d/backupbot
-# Create directories for backup scripts and logs
-RUN mkdir -p /usr/local/bin /config/log /config/web /etc/services.d/backupbot
-
-# Copy backup script
+# Copy the backup script
COPY backup.sh /usr/local/bin/backup.sh
RUN chmod +x /usr/local/bin/backup.sh
-# Copy the environment variables for the config
-COPY backupbot.env /defaults/backupbot.env
+# Copy the environment variables for backupbot
+COPY backupbot.conf /defaults/backupbot.conf
+RUN chown www-data:www-data /defaults/backupbot.conf \
+ && chmod 644 /defaults/backupbot.conf
# Copy s6 service for backupbot
COPY services/backupbot/run /etc/services.d/backupbot/run
RUN chmod +x /etc/services.d/backupbot/run
# Copy web frontend
-COPY web /defaults/web
-RUN chmod +x /defaults/web/cgi-bin/backupbot.cgi
+COPY web /app
+RUN chmod +x /app/cgi-bin/backupbot.cgi
# Expose web frontend port
EXPOSE 8080
diff --git a/backup.sh b/backup.sh
index 9ecaaa2..d5baa34 100644
--- a/backup.sh
+++ b/backup.sh
@@ -4,7 +4,6 @@
# Author: Calahil Studios
# === CONFIGURATION ===
-LOG_FILE="$1"
BACKUP_DIR="/backups/postgres_dumps"
RETENTION_DAYS="${RETENTION_DAYS:-7}" # Keep 7 days of backups
@@ -19,12 +18,12 @@ ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
EOF
)
-echo "[BACKUPBOT_INFO] Starting PostgreSQL backup service..." | tee -a "$LOG_FILE"
+echo "[BACKUPBOT_INFO] Starting PostgreSQL backup service..."
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +'%Y-%m-%d_%H-%M-%S')
-echo "[BACKUPBOT_INFO] $(date) - Starting backup cycle ($TIMESTAMP)" | tee -a "$LOG_FILE"
-echo "[BACKUPBOT_INFO] Checking for running Postgres containers..." | tee -a "$LOG_FILE"
+echo "[BACKUPBOT_INFO] $(date) - Starting backup cycle ($TIMESTAMP)"
+echo "[BACKUPBOT_INFO] Checking for running Postgres containers..."
# Find running containers matching known image names
MATCHING_CONTAINERS=$(
@@ -41,7 +40,7 @@ MATCHING_CONTAINERS=$(
)
if [ -z "$MATCHING_CONTAINERS" ]; then
- echo "[BACKUPBOT_WARN] No Postgres containers found." | tee -a "$LOG_FILE"
+ echo "[BACKUPBOT_WARN] No Postgres containers found."
else
for container in $MATCHING_CONTAINERS; do
NAME=$(docker inspect --format '{{.Name}}' "$container" | sed 's#^/##')
@@ -54,16 +53,16 @@ else
PG_USER=$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container" | grep POSTGRES_USER | cut -d= -f2)
PG_PASS=$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container" | grep POSTGRES_PASSWORD | cut -d= -f2)
if docker exec -e PGPASSWORD="$PG_PASS" "$container" pg_dumpall -U "$PG_USER" -h 127.0.0.1 >"$FILE" 2>/tmp/pg_backup_error.log; then
- echo "[BACKUPBOT_SUCCESS] Backup complete for $NAME -> $FILE" | tee -a "$LOG_FILE"
+ echo "[BACKUPBOT_SUCCESS] Backup complete for $NAME -> $FILE"
else
- echo "[BACKUPBOT_ERROR] Backup failed for $NAME (check /tmp/pg_backup_error.log)" | tee -a "$LOG_FILE"
+ echo "[BACKUPBOT_ERROR] Backup failed for $NAME (check /tmp/pg_backup_error.log)"
fi
# Retention cleanup
find "$CONTAINER_BACKUP_DIR" -type f -mtime +$RETENTION_DAYS -name '*.sql' -delete
done
fi
-echo "[BACKUPBOT_INFO] Creating a snapshot of /srv/appdata" | tee -a "$LOG_FILE"
+echo "[BACKUPBOT_INFO] Creating a snapshot of /srv/appdata"
btrfs subvolume snapshot -r /source/appdata /backups/snapshots/$(hostname)-$(date +%F)
-echo "[BACKUPBOT_INFO] Backup cycle complete." | tee -a "$LOG_FILE"
+echo "[BACKUPBOT_INFO] Backup cycle complete."
diff --git a/backupbot.env b/backupbot.conf
similarity index 87%
rename from backupbot.env
rename to backupbot.conf
index 9e7130d..483d3c2 100644
--- a/backupbot.env
+++ b/backupbot.conf
@@ -6,3 +6,4 @@ GOTIFY_URL=http://gotify.example.com
GOTIFY_TOKEN=your_gotify_token_here
BACKUP_HOUR=03
BACKUP_MINUTE=00
+BACKUPBOT_WEB_LOGGING=DEBUG
diff --git a/docker-compose.yml b/docker-compose.yml
index 4a6e88d..a1cd073 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,8 +4,8 @@ services:
container_name: backupbot
privileged: true
environment:
- - PUID=0
- - PGID=0
+ - PUID=1000
+ - PGID=1000
- TZ=Etc/UTC
- SETTINGS_ENCRYPTION_KEY=${KEY}
- CLI_ARGS= #optional
diff --git a/services/backupbot/run b/services/backupbot/run
index 3f9b0c5..6d82a03 100644
--- a/services/backupbot/run
+++ b/services/backupbot/run
@@ -1,37 +1,25 @@
#!/usr/bin/with-contenv bash
set -e
# Source env if available
-if [[ -f /config/backupbot.env ]]; then
+if [[ -f /config/backupbot.conf ]]; then
set -a
- source /config/backupbot.env
+ source /config/backupbot.conf
set +a
else
- echo "[INFO] copying env vars from defaults..."
- cp -r /defaults/backupbot.env /config/
+ echo "[INFO] copying config vars from defaults..."
+ cp -r /defaults/backupbot.conf /config/
set -a
- source /config/backupbot.env
+ source /config/backupbot.conf
set +a
fi
-# Initialize default web interface if missing
-if [ ! -d /config/web ]; then
- echo "[INFO] Populating /config/web from defaults..."
- cp -r /defaults/web /config/
-fi
# Start Python HTTP server for web config in background
-cd /config/web
+cd /app
-if [ ! -f /config/log/web.log ]; then
- mkdir -p /config/log
- touch /config/log/web.log
-fi
-
-nohup python3 -m http.server 8080 --cgi >/config/log/web.log 2>&1 &
+nohup python3 -m http.server 8080 --cgi 2>&1 &
# Start backup scheduler
STATE_FILE="/config/last_backup_date"
-LOG_FILE="/config/log/pgbackup.log"
-mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$LOG_FILE")"
# TZ
: "${TZ:=UTC}"
@@ -56,7 +44,7 @@ run_backup() {
local attempt=1
while ((attempt <= RETRIES)); do
echo "[INFO] Backup attempt $attempt"
- if /usr/local/bin/backup.sh "$LOG_FILE"; then
+ if /usr/local/bin/backup.sh; then
echo "[SUCCESS] Backup completed"
return 0
else
diff --git a/web/cgi-bin/backupbot.cgi b/web/cgi-bin/backupbot.cgi
index 39bd935..699eb76 100644
--- a/web/cgi-bin/backupbot.cgi
+++ b/web/cgi-bin/backupbot.cgi
@@ -3,41 +3,81 @@ import cgi
import cgitb
import os
import json
-import glob
+import sys
+import traceback
+import tempfile
cgitb.enable()
print("Content-Type: application/json\n")
-ENV_FILE = "/config/backupbot.env"
+ENV_FILE = "/config/backupbot.conf"
ZONEINFO_DIR = "/usr/share/zoneinfo"
+# Logging level from environment
+LOG_LEVEL = os.environ.get("BACKUPBOT_WEB_LOGGING", "info").lower()
+LOG_LEVELS = {"debug": 3, "info": 2, "warn": 1}
+
+
+def log(level, message, exc=None):
+ """
+ Docker-friendly logging.
+ level: "debug", "info", "warn"
+ exc: exception object (only used in debug)
+ """
+ if LOG_LEVELS.get(level, 0) <= LOG_LEVELS.get(LOG_LEVEL, 0):
+ timestamp = (
+ __import__("datetime")
+ .datetime.now()
+ .strftime(
+ "%Y-%m-%d \
+ %H:%M:%S"
+ )
+ )
+ msg = f"[{timestamp}] [{level.upper()}] {message}"
+ print(msg, file=sys.stderr)
+ if exc and LOG_LEVEL == "debug":
+ traceback.print_exception(
+ type(exc), exc, exc.__traceback__, file=sys.stderr
+ )
+
def read_env():
env = {}
if os.path.exists(ENV_FILE):
- with open(ENV_FILE) as f:
- for line in f:
- line = line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, val = line.split("=", 1)
- key = key.strip()
- val = val.strip().split("#")[0].strip()
- env[key] = val
+ try:
+ with open(ENV_FILE) as f:
+ for line in f:
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+ key, val = line.split("=", 1)
+ env[key.strip()] = val.strip()
+ except Exception as e:
+ log("warn", f"Failed to read config: {e}", e)
return env
def write_env(env):
- with open(ENV_FILE, "w") as f:
- for key, val in env.items():
- f.write(f"{key}={val}\n")
+ try:
+ dir_name = os.path.dirname(ENV_FILE)
+ os.makedirs(dir_name, exist_ok=True)
+ # Write atomically to temp file
+ with tempfile.NamedTemporaryFile("w", dir=dir_name, delete=False) as tmp:
+ for key, val in env.items():
+ tmp.write(f"{key}={val}\n")
+ temp_name = tmp.name
+ os.replace(temp_name, ENV_FILE)
+ log("info", f"Configuration saved to {ENV_FILE}")
+ except Exception as e:
+ log("warn", f"Failed to write config: {e}", e)
+ raise
def list_timezones():
zones = []
for root, _, files in os.walk(ZONEINFO_DIR):
rel_root = os.path.relpath(root, ZONEINFO_DIR)
- if rel_root.startswith("posix") or rel_root.startswith("right"):
+ if rel_root.startswith(("posix", "right")):
continue
for file in files:
if file.startswith(".") or file.endswith((".tab", ".zi")):
@@ -49,18 +89,27 @@ def list_timezones():
form = cgi.FieldStorage()
action = form.getvalue("action")
-if action == "get":
- print(json.dumps(read_env()))
-elif action == "set":
- try:
- raw = os.environ.get("CONTENT_LENGTH")
- length = int(raw) if raw else 0
+try:
+ if action == "get":
+ env = read_env()
+ log("debug", f"Returning configuration: {env}")
+ print(json.dumps(env))
+ elif action == "set":
+ raw_len = os.environ.get("CONTENT_LENGTH")
+ length = int(raw_len) if raw_len else 0
data = json.loads(os.read(0, length))
- write_env(data)
+ log("debug", f"Received new configuration: {data}")
+ env = read_env()
+ env.update(data) # update existing keys, add new keys
+ write_env(env)
print(json.dumps({"status": "ok", "message": "Configuration saved."}))
- except Exception as e:
- print(json.dumps({"status": "error", "message": str(e)}))
-elif action == "get_timezones":
- print(json.dumps({"timezones": list_timezones()}))
-else:
- print(json.dumps({"status": "error", "message": "Invalid action"}))
+ elif action == "get_timezones":
+ zones = list_timezones()
+ log("debug", f"Returning {len(zones)} timezones")
+ print(json.dumps({"timezones": zones}))
+ else:
+ log("warn", f"Invalid action requested: {action}")
+ print(json.dumps({"status": "error", "message": "Invalid action"}))
+except Exception as e:
+ log("warn", f"Unhandled exception: {e}", e)
+ print(json.dumps({"status": "error", "message": str(e)}))
diff --git a/web/index.html b/web/index.html
index 56e50cd..dd8591f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -37,10 +37,12 @@