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
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ docs/
tests/
*.db
*.sqlite3

.local/
.media/
deploy/secrets/
deploy/*.env
21 changes: 21 additions & 0 deletions .github/workflows/release-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Release configuration checks

on:
pull_request:
paths: ['tools/**', '.github/workflows/**', 'pyproject.toml', 'deploy/**']
push:
branches: [master]
paths: ['tools/**', '.github/workflows/**', 'pyproject.toml', 'deploy/**']

permissions:
contents: read

jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m unittest discover -s tools/tests -v
84 changes: 84 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Release container

on:
push:
branches: [master]
paths: ['pyproject.toml']
workflow_dispatch:

permissions:
contents: read

concurrency:
group: server-release-${{ github.ref }}
cancel-in-progress: false

jobs:
version:
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
outputs:
release: ${{ steps.version.outputs.release }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m unittest discover -s tools/tests -v
- name: Check committed version
id: version
env:
BEFORE: ${{ github.event.before }}
EVENT_NAME: ${{ github.event_name }}
run: |
if [ "$EVENT_NAME" = workflow_dispatch ]; then
python tools/release_gate.py server --manual
else
python tools/release_gate.py server --base "$BEFORE"
fi
image:
needs: version
if: needs.version.outputs.release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
id: image
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/papyrusreader/server:${{ needs.version.outputs.version }}
ghcr.io/papyrusreader/server:sha-${{ github.sha }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.version=${{ needs.version.outputs.version }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Record published digest
env:
DIGEST: ${{ steps.image.outputs.digest }}
VERSION: ${{ needs.version.outputs.version }}
run: printf 'ghcr.io/papyrusreader/server:%s\nDigest %s\n' "$VERSION" "$DIGEST" > image.txt
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
files: image.txt
14 changes: 10 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
FROM python:3.12-slim AS builder
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev
COPY papyrus/ ./papyrus/
RUN uv sync --locked --no-dev --no-editable

FROM python:3.12-slim AS runtime
WORKDIR /app
RUN groupadd --gid 10001 papyrus && useradd --uid 10001 --gid papyrus --no-create-home papyrus \
&& mkdir -p /var/lib/papyrus/media && chown papyrus:papyrus /var/lib/papyrus/media
COPY --from=builder /app/.venv /app/.venv
COPY papyrus/ ./papyrus/
COPY alembic/ ./alembic/
COPY alembic.ini ./
ENV PATH="/app/.venv/bin:$PATH"
ENV PATH="/app/.venv/bin:$PATH" \
MEDIA_STORAGE_ROOT="/var/lib/papyrus/media" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
USER papyrus
EXPOSE 8080
CMD ["papyrus-server"]
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,10 @@ uv run --locked python scripts/export_openapi.py ../docs/_static/openapi.json --
Export does not load `.env`, start services or connect to a database. Set the docs
workflow's server revision to the source commit used for the snapshot. The runtime
`/openapi.json` remains the specification for a configured deployment.

## Production releases

See [the deployment runbook](deploy/README.md) for version-triggered GHCR images
and the separate production Compose stack, HTTPS, PowerSync, migrations and
persistent database/media backups. API version metadata follows the installed
package version in `pyproject.toml`.
4 changes: 4 additions & 0 deletions deploy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
production.env
secrets/
web/
backups/
17 changes: 17 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
email {$ACME_EMAIL}
}

{$API_DOMAIN} {
reverse_proxy api:8080
}

{$SYNC_DOMAIN} {
reverse_proxy powersync:8080
}

{$APP_DOMAIN} {
root * /srv/web
try_files {path} /index.html
file_server
}
113 changes: 113 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Single-server production deployment

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.

## Host and domains

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). Databases and PowerSync's internal
listener have **no host port mappings**.

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
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`.

## First 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:

```sh
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.pem
```

Edit `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.

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. 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:

```sh
./deploy.sh
```

The 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.

## Updating and recovery

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.

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.
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:

```sh
docker compose --env-file production.env -f compose.yml ps
docker compose --env-file production.env -f compose.yml logs --tail 100 api powersync
```

Do 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:
- [Hetzner Cloud firewalls](https://docs.hetzner.com/cloud/firewalls/overview/)
- [Docker Compose deployment](https://docs.docker.com/compose/how-tos/production/)
- [Caddy automatic HTTPS](https://caddyserver.com/docs/automatic-https)
12 changes: 12 additions & 0 deletions deploy/bootstrap-powersync.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
\set ON_ERROR_STOP on
\getenv source_password POWERSYNC_SOURCE_PASSWORD
SELECT 'CREATE ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN'
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'powersync_role')
\gexec
ALTER ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN PASSWORD :'source_password';
GRANT USAGE ON SCHEMA public TO powersync_role;
GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items TO powersync_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role;
SELECT 'CREATE PUBLICATION powersync' WHERE NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync')
\gexec
ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items;
Loading
Loading