diff --git a/deploy/Caddyfile b/deploy/Caddyfile index fe64597..df8ee1d 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,16 +1,4 @@ -{ - email {$ACME_EMAIL} -} - -{$API_DOMAIN} { - reverse_proxy api:8080 -} - -{$SYNC_DOMAIN} { - reverse_proxy powersync:8080 -} - -{$APP_DOMAIN} { +:8080 { root * /srv/web try_files {path} /index.html file_server diff --git a/deploy/README.md b/deploy/README.md index a5866f1..891ffd1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -16,19 +16,28 @@ Use a VM with Docker Engine/Compose v2 and Python 3.12+, enough disk for uploade books, and off-host backups. Avoid sizing from an untested load estimate; monitor memory, database storage and replication lag during internal testing. Configure SSH key access and a Hetzner firewall allowing SSH from your own IP and public -TCP 80/443 (UDP 443 is optional for HTTP/3). Databases and PowerSync's internal -listener have **no host port mappings**. +TCP 80/443 (UDP 443 is optional for HTTP/3). This Compose project publishes no +host ports. Databases remain on its private network; only API, sync and the +Flutter web server join the external `papyrus-edge` network. The registered domain is `papyrus-reader.com`. Use `api.papyrus-reader.com`, `sync.papyrus-reader.com` and `app.papyrus-reader.com`. -Point their DNS A records to the VM (add AAAA only if IPv6 routing works). Caddy -obtains and renews HTTPS certificates and proxies PowerSync streaming. It also -serves the built Flutter web app for verification/password-reset links. The client +Point their DNS A records to the VM (add AAAA only if IPv6 routing works). The +independent `papyrus-edge` Compose project owns public ports and HTTPS certificates. +Its configuration is in the workspace repository's `deploy/edge` directory and +uses `papyrus-api:8080`, `papyrus-sync:8080` and `papyrus-app:8080` as upstreams. +The `web` service here serves the built Flutter app over internal HTTP, including +verification/password-reset routes. The client release environment must use the same API/sync origins. Set the Google OAuth web client's authorized redirect URI to `https://api.papyrus-reader.com/v1/auth/oauth/google/callback`; mobile callbacks remain `papyrus://auth/callback`. +Provision the shared edge project and its `papyrus-edge` network before deploying +these services. Keep domain values aligned with `edge.env`; the ACME contact email +belongs there. The public landing page is a separate Compose project owned by the +website repository and is not started, stopped or mounted by this deployment. + ## First deployment Check out the server release's source so PowerSync config and migrations match @@ -97,8 +106,9 @@ or assume rolling back an image reverses a data migration. Before each release, take a PostgreSQL dump of the application database and a consistent media backup. Keep encrypted, off-host backups of the database, media, production environment and JWT keys; perform an actual restore drill. The named -volumes retain application/PostgreSQL/PowerSync data and Caddy certificates across -container replacement. **Do not use `docker compose down -v`** for upgrades. +volumes retain application/PostgreSQL/PowerSync data across container replacement. +The shared proxy's separate volumes retain certificates. **Do not use +`docker compose down -v`** for upgrades. VM snapshots alone are not a verified database/media backup. PowerSync storage can be rebuilt, but doing so requires coordinated client resync. diff --git a/deploy/compose.yml b/deploy/compose.yml index 5fb9665..f4b027c 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -62,6 +62,10 @@ services: api: <<: *api restart: unless-stopped + networks: + default: + edge: + aliases: [papyrus-api] healthcheck: test: ['CMD', 'python', '-c', "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=2)"] interval: 10s @@ -70,6 +74,10 @@ services: powersync: image: journeyapps/powersync-service:1.23.0 restart: unless-stopped + networks: + default: + edge: + aliases: [papyrus-sync] command: ['start', '-r', 'unified'] environment: POWERSYNC_CONFIG_PATH: /config/service.yaml @@ -89,29 +97,27 @@ services: interval: 10s timeout: 3s retries: 15 - proxy: + web: image: caddy:2.10.2-alpine restart: unless-stopped - ports: ['80:80', '443:443', '443:443/udp'] - environment: - API_DOMAIN: ${API_DOMAIN} - SYNC_DOMAIN: ${SYNC_DOMAIN} - APP_DOMAIN: ${APP_DOMAIN:?Set APP_DOMAIN for email verification and password reset} - ACME_EMAIL: ${ACME_EMAIL:?Set ACME_EMAIL} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy-data:/data - - caddy-config:/config - ./web:/srv/web:ro - depends_on: - api: - condition: service_healthy - powersync: - condition: service_healthy + networks: + edge: + aliases: [papyrus-app] + healthcheck: + test: ['CMD', 'wget', '--spider', '-q', 'http://127.0.0.1:8080/'] + interval: 5s + timeout: 3s + retries: 10 + +networks: + edge: + external: true + name: papyrus-edge volumes: database: powersync-storage: media: - caddy-data: - caddy-config: diff --git a/deploy/deploy.sh b/deploy/deploy.sh index f661f82..7a4329a 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -4,10 +4,11 @@ cd "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" python3 validate_config.py compose() { docker compose --env-file production.env -f compose.yml "$@"; } compose config --quiet +docker network inspect papyrus-edge >/dev/null compose pull compose up -d --wait database powersync-storage -compose stop api powersync proxy +compose stop api powersync web compose run --rm migrate # shellcheck disable=SC2016 compose exec -T database sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /bootstrap-powersync.sql' -compose up -d --wait api powersync proxy +compose up -d --wait api powersync web diff --git a/deploy/production.env.example b/deploy/production.env.example index 38b1188..f7dd7cb 100644 --- a/deploy/production.env.example +++ b/deploy/production.env.example @@ -2,7 +2,6 @@ PAPYRUS_VERSION=0.0.1 API_DOMAIN=api.papyrus-reader.com SYNC_DOMAIN=sync.papyrus-reader.com APP_DOMAIN=app.papyrus-reader.com -ACME_EMAIL= DEBUG=false HOST=0.0.0.0 PORT=8080 diff --git a/deploy/validate_config.py b/deploy/validate_config.py index daf610c..bd0d28f 100644 --- a/deploy/validate_config.py +++ b/deploy/validate_config.py @@ -24,7 +24,7 @@ def validate(values: dict[str, str]) -> None: for key in ("POSTGRES_USER", "POSTGRES_DB", "POWERSYNC_STORAGE_USER", "POWERSYNC_STORAGE_DB"): if not re.fullmatch(r"[a-z][a-z0-9_]*", values.get(key, "")): raise ValueError(f"{key} must be a lowercase database identifier") - for key in ("ACME_EMAIL", "SMTP_HOST", "SMTP_FROM_EMAIL", "POWERSYNC_JWT_KEY_ID", "POWERSYNC_JWT_AUDIENCE"): + for key in ("SMTP_HOST", "SMTP_FROM_EMAIL", "POWERSYNC_JWT_KEY_ID", "POWERSYNC_JWT_AUDIENCE"): if not values.get(key): raise ValueError(f"{key} is required") if values.get("APP_PUBLIC_BASE_URL") != f"https://{values['APP_DOMAIN']}": diff --git a/tools/tests/test_deploy.py b/tools/tests/test_deploy.py index 87b7306..7e20da7 100644 --- a/tools/tests/test_deploy.py +++ b/tools/tests/test_deploy.py @@ -41,6 +41,11 @@ def values(self) -> dict[str, str]: def test_valid_config(self) -> None: config.validate(self.values()) + def test_app_deployment_does_not_require_proxy_contact_settings(self) -> None: + values = self.values() + del values["ACME_EMAIL"] + config.validate(values) + def test_placeholder_secret_or_url_drift_is_rejected(self) -> None: for key, value in ( ("API_DOMAIN", "api.example.com"),