This is a portable Docker Compose deployment for a Linux VM, including Hetzner
Cloud. It is separate from the local docker-compose.yml. Nothing here provisions
or changes a live server automatically.
The API and migration job use ghcr.io/papyrusreader/server:<version>, built for
amd64 and arm64 when the committed server version changes on master. API version
metadata comes from the installed Python package. GitHub releases record the
image digest; deploy a recorded digest instead of a mutable tag when stronger
artifact pinning is needed. The runtime uses UID/GID 10001, not root.
Feature/fix PRs target the default development branch and leave versions
unchanged. CI checks integration work without publishing a container. When ready,
prepare the coordinated version bump on development, then promote it to
master with a release PR using Create a merge commit. The existing version
gate publishes only from master. Bring master back into development after
the release, and deploy additive server changes before a client that needs them.
See the workspace RELEASING.md for coordination and merge order.
Use a VM with Docker Engine/Compose v2 and Python 3.12+, enough disk for uploaded
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). 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). The
independent papyrus-edge Compose project owns public ports and HTTPS certificates.
Its configuration is in PapyrusReader/observability (observability/edge in the workspace) 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.
Windows and Linux apps receive the server's login result at
http://localhost:43821/auth/callback. The API allows this exact desktop callback
in production without adding localhost to OAUTH_ALLOWED_REDIRECT_HOSTS.
Google still redirects to the public API callback above. Do not register the
desktop loopback URL as the Google web client's authorized redirect URI.
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.
Check out the server release's source so PowerSync config and migrations match
the container version. Run these commands from server/deploy on the VM:
cp production.env.example production.env
chmod 600 production.env
mkdir -p secrets web
chmod 700 secrets
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out secrets/powersync-private.pem
openssl pkey -in secrets/powersync-private.pem -pubout -out secrets/powersync-public.pem
sudo chown 10001:10001 secrets/powersync-private.pem secrets/powersync-public.pem
chmod 400 secrets/powersync-private.pem
chmod 444 secrets/powersync-public.pemEdit production.env locally on the VM. The domain, APP_PUBLIC_BASE_URL, CORS and allowed web redirect hosts already
match papyrus-reader.com; keep these aligned if you change a hostname. Generate
separate values for SECRET_KEY, POSTGRES_PASSWORD,
POWERSYNC_SOURCE_PASSWORD and POWERSYNC_STORAGE_PASSWORD using
openssl rand -hex 32. Database values must be URL-safe because connection URLs
are assembled from them. Do not rotate the JWT private key on ordinary deploys.
Set FORWARDED_ALLOW_IPS to the Caddy container's address on papyrus-edge:
docker inspect papyrus-edge-proxy-1 --format '{{(index .NetworkSettings.Networks "papyrus-edge").IPAddress}}'Use that single private address in production.env, never * or the entire Docker
subnet. The API must remain private behind Caddy. Caddy supplies the client address
for IP-based authentication and OPDS relay limits. If the proxy is recreated with a
different address, update this value and recreate the API container too.
Sync uploads and media uploads use the authenticated account ID after session validation. Devices and sessions for one account share an allowance, while separate accounts remain independent even behind the same proxy or home network.
The recommended initial values are RATE_LIMIT_BATCH=300 and
RATE_LIMIT_UPLOAD=60 per minute. A sync request contains one client transaction,
which can contain multiple row mutations. Offline backlogs naturally create bursts.
These values are starting limits, not measured capacity guarantees. Keep the
authentication limit at 5 and OPDS relay limit at 100 requests/minute per client IP.
The current limiter is in memory for the single API process. Use shared limiter
storage before scaling to multiple workers or replicas.
Existing deployments must update these two values explicitly in production.env.
Changing the example file does not change an installed configuration. Deploy the
released server image through the normal release process, then verify independent
accounts behind the proxy, 429 responses with Retry-After, and public probes.
The matching client suppresses repeated upload attempts during the cooldown and
leaves failed transactions queued for a later attempt. No migration or library reset
is required.
Configure a real SMTP provider with TLS, verified sender, and its credentials;
Mailpit is for local development. Add Google OAuth credentials if testing Google
sign-in. Extract the matching client's web-release artifact into web/, so
web/index.html exists, then run python3 migrate_web_layout.py to initialize
web/current/index.html. This preserves the flat files and snapshots the initial
release. Subsequent client releases activate versioned directories atomically. The mobile app does not require visiting the web app for
ordinary reading, but registration verification/reset emails use its routes.
If the GHCR package is private, authenticate Docker on the VM with a restricted read-packages token; never reuse the CI publishing token. Ensure the requested image has actually been published. Then:
./deploy.shThe script validates settings without printing credentials, pulls images, waits for both databases, stops app services, runs Alembic once, creates/updates the restricted PowerSync replication role and publication, then starts services with health checks. It intentionally causes a short maintenance window. If a migration fails, services stay stopped; inspect the failure before restoring service. Migrations do not run independently in every API replica.
Verify externally: https://api.papyrus-reader.com/health, /openapi.json (release version),
https://sync.papyrus-reader.com/probes/liveness, the web app, SMTP verification/reset, Google
sign-in, book/media upload and sync from a Play-installed Android build. Monitor
PowerSync replication slots: a 1 GB WAL retention cap protects disk but an
extended outage can invalidate a slot and require a controlled resync.
The initial testing baseline is 0.0.1. Its release workflow removes the unused
1.0.0 container by its exact manifest digest after publishing the replacement.
It preserves shared manifests and refuses to delete versions with unrelated tags.
The package itself stays public. This cleanup uses the publishing repository's
package admin access through GITHUB_TOKEN and only runs for 0.0.1.
Deploy the backward-compatible server first, then roll out the client. Update
PAPYRUS_VERSION, check out the corresponding source/config and install the web
artifact before running ./deploy.sh. Check Alembic current/head before/after a
release and investigate alembic check differences. Do not downgrade migrations
or assume rolling back an image reverses a data migration.
The publication bootstrap reads the release SQL through standard input. A running
database container can retain an older inode for its individual file bind mount
after configuration replacement; do not execute the mounted file during upgrades.
Verify that pg_publication_tables includes every configured sync table.
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 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.
For commands outside the script, always use:
docker compose --env-file production.env -f compose.yml ps
docker compose --env-file production.env -f compose.yml logs --tail 100 api powersyncDo not expose logs containing tokens or environment values when asking for help. The first release still needs public DNS, SMTP/OAuth setup, credentials and a real-device sync/auth test; local container compilation is not a deployment test.
References:
For an existing deployment, run python3 migrate_web_layout.py from deploy/
before installing the updated Caddyfile. It leaves the running flat directory
intact. Copy the new Caddyfile into the existing bind-mounted file (preserve its
inode), then validate and reload only the web process:
docker compose --env-file production.env -f compose.yml exec -T web caddy validate --config /etc/caddy/Caddyfile
docker compose --env-file production.env -f compose.yml exec -T web caddy reload --config /etc/caddy/CaddyfileKeep the parent web directory mounted at /srv/web. Check / and /login after
migration. Client delivery credentials and the restricted receiver are documented
in the client's docs/RELEASING.md. This migration needs no database changes and
must not run the full server deployment script or restart API/PowerSync services.
PowerSync exposes Prometheus metrics on private container port 9090. The shared
observability collector joins papyrus-production_default and scrapes that port.
Do not publish it on the host or add a public Caddy route. Applying this YAML
change only requires restarting PowerSync, followed by its readiness check.
The PapyrusReader/observability repository owns dashboards, collection, alerts,
and the bootstrap for restricted papyrus_monitor database roles.