From 1297a839d44ca185921cc4415797d7a265435927 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 15:33:09 +0200 Subject: [PATCH] (config) Harden Docker deployment for remote servers Run the Django control panel with Gunicorn instead of the development runserver and serve static files through WhiteNoise. Add restart policies, healthchecks, .env-driven production settings, and a sample .env file for single-server deployments. Update the Docker entrypoint to collect static assets and document the remote server deployment and update flow in the README. --- .env.example | 7 ++++ Dockerfile | 2 +- README.md | 44 +++++++++++++++++++++- docker-compose.yml | 69 ++++++++++++++++++++++++++-------- pyproject.toml | 2 + scripts/docker-entrypoint | 1 + src/pobsync_server/settings.py | 6 +++ 7 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6920016 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync +POBSYNC_DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backup.example.com +POBSYNC_DJANGO_SECRET_KEY=change-me-to-a-long-random-secret +POBSYNC_DJANGO_DEBUG=0 +POBSYNC_WEB_BIND=127.0.0.1 +POBSYNC_GUNICORN_WORKERS=2 +POBSYNC_GUNICORN_TIMEOUT=120 diff --git a/Dockerfile b/Dockerfile index 33a1db5..5bcf687 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,4 @@ RUN chmod +x ./scripts/docker-entrypoint EXPOSE 8000 ENTRYPOINT ["./scripts/docker-entrypoint"] -CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] +CMD ["gunicorn", "pobsync_server.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"] diff --git a/README.md b/README.md index cd84b6f..4743a6b 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ Run the scheduler alongside the web admin: docker compose up --build web scheduler worker ``` -The container persists `/opt/pobsync` and the SQLite database in Docker volumes. +The web service runs Django through Gunicorn and serves static files with WhiteNoise. The container persists `/opt/pobsync` +and the SQLite database in Docker volumes. Backup data is always available at `/backups` inside the containers. By default this uses `./backups` on the host. Override the host-side mount with `POBSYNC_BACKUP_ROOT`: @@ -139,6 +140,47 @@ POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync docker compose up --build web scheduler The Django setup UI keeps the backup root fixed at `/backups`; only the Docker mount decides which host directory that points to. +## Remote Server Deployment + +For a single backup server, use Docker Compose with the SQLite services and put a reverse proxy such as Caddy, nginx, +or Traefik in front of `web`. + +Create a `.env` from the example: + +``` +cp .env.example .env +``` + +Set at least: + +``` +POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync +POBSYNC_DJANGO_ALLOWED_HOSTS=backup.example.com,localhost,127.0.0.1 +POBSYNC_DJANGO_SECRET_KEY= +POBSYNC_DJANGO_DEBUG=0 +POBSYNC_WEB_BIND=127.0.0.1 +``` + +Deploy or update: + +``` +git pull +docker compose build web scheduler worker +docker compose up -d --force-recreate web scheduler worker +docker compose exec web python manage.py migrate +``` + +Check service state: + +``` +docker compose ps +docker compose logs --tail=100 worker +docker compose logs --tail=100 scheduler +``` + +`web`, `scheduler`, and `worker` use `restart: unless-stopped` and Docker healthchecks. If `POBSYNC_WEB_BIND` is +`127.0.0.1`, expose the app through your reverse proxy instead of directly publishing it to the internet. + ## Django-Managed SSH Keys SSH keys can be managed from the Django UI at `/ssh-credentials/`. Add a private key there, optionally paste diff --git a/docker-compose.yml b/docker-compose.yml index 7eb4233..35ea1b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,26 +1,33 @@ services: web: build: . - command: python manage.py runserver 0.0.0.0:8000 + command: gunicorn pobsync_server.wsgi:application --bind 0.0.0.0:8000 --workers ${POBSYNC_GUNICORN_WORKERS:-2} --timeout ${POBSYNC_GUNICORN_TIMEOUT:-120} + restart: unless-stopped environment: - POBSYNC_DJANGO_DEBUG: "1" - POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me" + POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}" + POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}" POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}" POBSYNC_HOME: "/opt/pobsync" POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3" ports: - - "8010:8000" + - "${POBSYNC_WEB_BIND:-0.0.0.0}:8010:8000" volumes: - pobsync_state:/opt/pobsync - pobsync_db:/var/lib/pobsync - ${POBSYNC_BACKUP_ROOT:-./backups}:/backups + healthcheck: + test: ["CMD", "python", "manage.py", "check"] + interval: 30s + timeout: 10s + retries: 3 scheduler: build: . command: python manage.py run_pobsync_scheduler --loop --interval 60 + restart: unless-stopped environment: - POBSYNC_DJANGO_DEBUG: "1" - POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me" + POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}" + POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}" POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}" POBSYNC_HOME: "/opt/pobsync" POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3" @@ -28,13 +35,19 @@ services: - pobsync_state:/opt/pobsync - pobsync_db:/var/lib/pobsync - ${POBSYNC_BACKUP_ROOT:-./backups}:/backups + healthcheck: + test: ["CMD", "python", "manage.py", "check"] + interval: 30s + timeout: 10s + retries: 3 worker: build: . command: python manage.py run_pobsync_worker --loop --interval 15 + restart: unless-stopped environment: - POBSYNC_DJANGO_DEBUG: "1" - POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me" + POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}" + POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}" POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}" POBSYNC_HOME: "/opt/pobsync" POBSYNC_SQLITE_PATH: "/var/lib/pobsync/pobsync.sqlite3" @@ -42,14 +55,20 @@ services: - pobsync_state:/opt/pobsync - pobsync_db:/var/lib/pobsync - ${POBSYNC_BACKUP_ROOT:-./backups}:/backups + healthcheck: + test: ["CMD", "python", "manage.py", "check"] + interval: 30s + timeout: 10s + retries: 3 web-mariadb: profiles: ["mariadb"] build: . - command: python manage.py runserver 0.0.0.0:8000 + command: gunicorn pobsync_server.wsgi:application --bind 0.0.0.0:8000 --workers ${POBSYNC_GUNICORN_WORKERS:-2} --timeout ${POBSYNC_GUNICORN_TIMEOUT:-120} + restart: unless-stopped environment: - POBSYNC_DJANGO_DEBUG: "1" - POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me" + POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}" + POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}" POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}" POBSYNC_HOME: "/opt/pobsync" POBSYNC_DB_ENGINE: "mariadb" @@ -61,18 +80,24 @@ services: db: condition: service_healthy ports: - - "8010:8000" + - "${POBSYNC_WEB_BIND:-0.0.0.0}:8010:8000" volumes: - pobsync_state:/opt/pobsync - ${POBSYNC_BACKUP_ROOT:-./backups}:/backups + healthcheck: + test: ["CMD", "python", "manage.py", "check"] + interval: 30s + timeout: 10s + retries: 3 scheduler-mariadb: profiles: ["mariadb"] build: . command: python manage.py run_pobsync_scheduler --loop --interval 60 + restart: unless-stopped environment: - POBSYNC_DJANGO_DEBUG: "1" - POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me" + POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}" + POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}" POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}" POBSYNC_HOME: "/opt/pobsync" POBSYNC_DB_ENGINE: "mariadb" @@ -86,14 +111,20 @@ services: volumes: - pobsync_state:/opt/pobsync - ${POBSYNC_BACKUP_ROOT:-./backups}:/backups + healthcheck: + test: ["CMD", "python", "manage.py", "check"] + interval: 30s + timeout: 10s + retries: 3 worker-mariadb: profiles: ["mariadb"] build: . command: python manage.py run_pobsync_worker --loop --interval 15 + restart: unless-stopped environment: - POBSYNC_DJANGO_DEBUG: "1" - POBSYNC_DJANGO_SECRET_KEY: "dev-only-change-me" + POBSYNC_DJANGO_DEBUG: "${POBSYNC_DJANGO_DEBUG:-0}" + POBSYNC_DJANGO_SECRET_KEY: "${POBSYNC_DJANGO_SECRET_KEY:-dev-only-change-me}" POBSYNC_DJANGO_ALLOWED_HOSTS: "${POBSYNC_DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}" POBSYNC_HOME: "/opt/pobsync" POBSYNC_DB_ENGINE: "mariadb" @@ -107,10 +138,16 @@ services: volumes: - pobsync_state:/opt/pobsync - ${POBSYNC_BACKUP_ROOT:-./backups}:/backups + healthcheck: + test: ["CMD", "python", "manage.py", "check"] + interval: 30s + timeout: 10s + retries: 3 db: profiles: ["mariadb"] image: mariadb:11 + restart: unless-stopped environment: MARIADB_DATABASE: "pobsync" MARIADB_USER: "pobsync" diff --git a/pyproject.toml b/pyproject.toml index a6462a4..10efdfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ description = "Pull-based rsync backup tool with hardlinked snapshots" requires-python = ">=3.11" dependencies = [ "Django>=5.2,<6.0", + "gunicorn>=23.0,<24.0", + "whitenoise>=6.9,<7.0", "PyYAML>=6.0" ] diff --git a/scripts/docker-entrypoint b/scripts/docker-entrypoint index ed13e47..d869bc4 100644 --- a/scripts/docker-entrypoint +++ b/scripts/docker-entrypoint @@ -4,5 +4,6 @@ set -eu mkdir -p "$(dirname "${POBSYNC_SQLITE_PATH:-/var/lib/pobsync/pobsync.sqlite3}")" python manage.py migrate --noinput +python manage.py collectstatic --noinput --clear exec "$@" diff --git a/src/pobsync_server/settings.py b/src/pobsync_server/settings.py index 2f94e2b..b254510 100644 --- a/src/pobsync_server/settings.py +++ b/src/pobsync_server/settings.py @@ -24,6 +24,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -85,6 +86,11 @@ USE_TZ = True STATIC_URL = "static/" STATIC_ROOT = os.getenv("POBSYNC_STATIC_ROOT", str(BASE_DIR / "var" / "static")) +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"