From 9f68c7ec5dbfea5c1c1e21c4f6b2c5915e214363 Mon Sep 17 00:00:00 2001 From: Alcahest Date: Tue, 25 Aug 2026 19:06:53 +0200 Subject: [PATCH] feat(deploy): support a reverse proxy already on the server --- .env.example | 10 +++++++++- DEPLOY.md | 20 +++++++++++++++----- docker-compose.proxy.yml | 17 +++++++++++++++++ scripts/deploy.sh | 25 +++++++++++++++++++++---- 4 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 docker-compose.proxy.yml diff --git a/.env.example b/.env.example index 475a34d..dd76d8b 100644 --- a/.env.example +++ b/.env.example @@ -37,9 +37,17 @@ PROBUS_DATA=./data PROBUS_MEM_LIMIT=2g # --------------------------------------------------------------------------- -# TLS (only used by the `tls` profile) +# Facing the internet: pick one # --------------------------------------------------------------------------- +# A reverse proxy already on the server (Nginx Proxy Manager, Traefik, Caddy) +# that reaches services over a shared Docker network: name that network here +# and point the proxy at `busfinder:3000`. Leave empty to run our own Caddy, +# which needs the two TLS settings below instead. +PROBUS_PROXY_NETWORK= + +# TLS with our own Caddy (the `tls` profile), used when the line above is empty + # Domain whose A/AAAA record already points at this server. Caddy obtains and # renews the certificate itself; nothing else has to be configured. PROBUS_DOMAIN=busfinder.example.com diff --git a/DEPLOY.md b/DEPLOY.md index be9bbb7..cfa63f4 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -17,6 +17,7 @@ source: it pulls a ready-made image, the same one you tried here. |---|---| | `docker-compose.yml` | the stack. Same file here and on the server; only `.env` differs | | `docker-compose.build.yml` | overlay for building locally instead of pulling | +| `docker-compose.proxy.yml` | overlay for a server with its own reverse proxy, see step 4 | | `Caddyfile` | HTTPS with an automatic certificate. Only needed on the server | | `.env` | this machine | | `.env.server` | the server. `deploy.sh` copies it over as `.env` | @@ -54,9 +55,18 @@ point at the server, and ports 80 and 443 must be reachable, before the first deploy: Caddy fetches the certificate at startup and Let's Encrypt allows five attempts per week per domain. -**4. `.env.server`.** Fill in `PROBUS_IMAGE`, `PROBUS_DOMAIN`, -`PROBUS_ACME_EMAIL` and `PROBUS_CONTACT`. `deploy.sh` refuses to start if the -`CHANGEME` placeholders are still there. +**4. `.env.server`.** Fill in `PROBUS_IMAGE` and `PROBUS_CONTACT`, then pick +how the app faces the internet: + +- *Our own Caddy*: fill in `PROBUS_DOMAIN` and `PROBUS_ACME_EMAIL`. Caddy takes + ports 80 and 443 and fetches the certificate itself. +- *A reverse proxy already on the server* (Nginx Proxy Manager, Traefik, + another Caddy): set `PROBUS_PROXY_NETWORK` to the Docker network the proxy + uses to reach its services. The app joins it and the proxy is pointed at + `busfinder` on port `3000`; the certificate is the proxy's business, and our + Caddy is not started at all. + +`deploy.sh` refuses to start if a required value is still a `CHANGEME`. **5. The server.** All it needs is Docker and the compose plugin. `deploy.sh` creates `PROBUS_REMOTE_DIR` itself and copies the configuration and `.env` into @@ -71,11 +81,11 @@ prompt for a password. 1. refuses to start if `.env.server` still contains a `CHANGEME`; 2. notes which image is running right now, so it can go back to it; -3. copies `docker-compose.yml`, `Caddyfile` and `.env.server` to the server — +3. copies the compose files, `Caddyfile` and `.env.server` to the server — configuration only, never source; 4. pulls the new image and restarts; 5. waits up to five minutes for the container to become `healthy`; -6. runs `scripts/smoke.ts` against the public URL: eighteen checks across every +6. runs `scripts/smoke.ts` against the public URL: 47 checks across every route, including a full write-and-delete round trip through sync; 7. if the container never becomes healthy or the smoke test fails, it **puts the previous image back** and exits non-zero. diff --git a/docker-compose.proxy.yml b/docker-compose.proxy.yml new file mode 100644 index 0000000..eb3c0ea --- /dev/null +++ b/docker-compose.proxy.yml @@ -0,0 +1,17 @@ +# Overlay for a server where a reverse proxy (Nginx Proxy Manager, Traefik, +# another Caddy) already owns ports 80 and 443 and reaches services over a +# shared Docker network. The app joins that network and the proxy talks to it +# as `busfinder:3000`; the `tls` profile stays off. +# +# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d + +services: + app: + networks: + - default + - proxy + +networks: + proxy: + external: true + name: ${PROBUS_PROXY_NETWORK:?set PROBUS_PROXY_NETWORK to the proxy's Docker network} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 6ef05b8..cdc32c9 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -36,6 +36,16 @@ set +a : "${PROBUS_URL:?PROBUS_URL is not set in .env}" REMOTE_DIR="${PROBUS_REMOTE_DIR:-/opt/busfinder}" +# Two ways to face the internet: our own Caddy (tls profile), or a reverse +# proxy already on the host that reaches the app over a shared Docker network. +if [ -n "${PROBUS_PROXY_NETWORK:-}" ]; then + COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.proxy.yml) + MODE="proxy network ${PROBUS_PROXY_NETWORK}" +else + COMPOSE=(docker compose --profile tls) + MODE="caddy on ${PROBUS_DOMAIN:-?}" +fi + TARGET="${PROBUS_IMAGE}:${PROBUS_TAG}" SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10) @@ -44,7 +54,8 @@ remote() { ssh "${SSH_OPTS[@]}" "$PROBUS_SSH" "$@"; } # Caddy needs these and compose cannot demand them without breaking the # app-only stack, so this is where a missing domain has to be caught. -for key in PROBUS_DOMAIN PROBUS_ACME_EMAIL PROBUS_IMAGE; do +if [ -n "${PROBUS_PROXY_NETWORK:-}" ]; then REQUIRED="PROBUS_IMAGE"; else REQUIRED="PROBUS_DOMAIN PROBUS_ACME_EMAIL PROBUS_IMAGE"; fi +for key in $REQUIRED; do value="$(grep -E "^${key}=" .env.server | head -1 | cut -d= -f2-)" if [ -z "$value" ] || case "$value" in *CHANGEME*) true ;; *) false ;; esac; then echo "deploy: ${key} is not filled in in .env.server (value: '${value}')." >&2 @@ -54,6 +65,7 @@ done say "server ${PROBUS_SSH}:${REMOTE_DIR}" say "image ${TARGET}" +say "front ${MODE}" say "smoke ${PROBUS_URL}" echo @@ -62,6 +74,11 @@ if ! remote true; then exit 1 fi +if [ -n "${PROBUS_PROXY_NETWORK:-}" ] && ! remote "docker network inspect '${PROBUS_PROXY_NETWORK}' >/dev/null 2>&1"; then + echo "deploy: Docker network '${PROBUS_PROXY_NETWORK}' does not exist on ${PROBUS_SSH}." >&2 + exit 1 +fi + # What is running now, so a failed rollout can be undone. Empty on first deploy. PREVIOUS="$(remote "docker inspect -f '{{.Config.Image}}' busfinder 2>/dev/null || true" | tr -d '\r')" if [ -n "$PREVIOUS" ]; then @@ -72,7 +89,7 @@ fi say "sending the configuration" remote "mkdir -p '${REMOTE_DIR}'" -scp "${SSH_OPTS[@]}" -q docker-compose.yml Caddyfile "${PROBUS_SSH}:${REMOTE_DIR}/" +scp "${SSH_OPTS[@]}" -q docker-compose.yml docker-compose.proxy.yml Caddyfile "${PROBUS_SSH}:${REMOTE_DIR}/" scp "${SSH_OPTS[@]}" -q .env.server "${PROBUS_SSH}:${REMOTE_DIR}/.env" # The tag lives in the local .env; the server's copy must agree with it. remote "cd '${REMOTE_DIR}' && (grep -q '^PROBUS_TAG=' .env \ @@ -80,7 +97,7 @@ remote "cd '${REMOTE_DIR}' && (grep -q '^PROBUS_TAG=' .env \ || echo 'PROBUS_TAG=${PROBUS_TAG}' >> .env)" bring_up() { - remote "cd '${REMOTE_DIR}' && docker compose --profile tls pull --quiet && docker compose --profile tls up -d" + remote "cd '${REMOTE_DIR}' && ${COMPOSE[*]} pull --quiet && ${COMPOSE[*]} up -d" } say "pulling and restarting" @@ -102,7 +119,7 @@ rollback() { fi echo "deploy: rolling back to ${PREVIOUS}" >&2 old_tag="${PREVIOUS##*:}" - remote "cd '${REMOTE_DIR}' && sed -i 's|^PROBUS_TAG=.*|PROBUS_TAG=${old_tag}|' .env && docker compose --profile tls up -d" || true + remote "cd '${REMOTE_DIR}' && sed -i 's|^PROBUS_TAG=.*|PROBUS_TAG=${old_tag}|' .env && ${COMPOSE[*]} up -d" || true } if [ -z "$healthy" ]; then