Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 17 additions & 7 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
38 changes: 22 additions & 16 deletions deploy/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
5 changes: 3 additions & 2 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 0 additions & 1 deletion deploy/production.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion deploy/validate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}":
Expand Down
5 changes: 5 additions & 0 deletions tools/tests/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading