From 5b4fa81bc141407ede207fd5e8da5d1c2e2fe1bf Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Thu, 8 Oct 2026 23:09:34 -0500 Subject: [PATCH] Run the sync server as a plain container; stop cleanly on SIGTERM Drop the Compose/Caddy example: production runs on Kubernetes, and locally the container only needs a forwarded port and a volume. The README now gives the `docker run` steps and what a Kubernetes deployment needs (one replica, since it's a single SQLite file; a volume at /data; TLS at the ingress). The server only shut down on Ctrl-C, so `docker stop` and pod termination, which send SIGTERM, waited out the grace period and killed it. It now shuts down gracefully on either. Also ignore `.env`, which can hold the OIDC client secret. --- .gitignore | 3 ++ README.md | 42 +++++++++++++++++++++----- crates/mtgc-server/deploy/.env.example | 7 ++++- crates/mtgc-server/deploy/Caddyfile | 5 --- crates/mtgc-server/deploy/compose.yaml | 29 ------------------ crates/mtgc-server/src/main.rs | 26 ++++++++++++---- 6 files changed, 63 insertions(+), 49 deletions(-) delete mode 100644 crates/mtgc-server/deploy/Caddyfile delete mode 100644 crates/mtgc-server/deploy/compose.yaml diff --git a/.gitignore b/.gitignore index f0ae9cc..b82faf1 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ Thumbs.db .idea/ *.swp *.swo + +# Server settings, which may hold an OIDC client secret. Only the example is tracked. +.env diff --git a/README.md b/README.md index 718c281..0efa6bc 100644 --- a/README.md +++ b/README.md @@ -159,18 +159,44 @@ signed out from another one. ## Running the sync server -`mtgc-server` is a single binary with a SQLite database. Run it behind a -reverse proxy that provides HTTPS (the Android app refuses plain HTTP outside -debug builds). `crates/mtgc-server/deploy/` has a Docker Compose example with -Caddy: +`mtgc-server` is a single binary with a SQLite database, shipped as a +container image. Inside the container it listens on port 8080 and keeps its +database in `/data`; everything else is environment variables (below). + +To run it locally, build the image, then run it with the port forwarded to +localhost and a named volume for the data: ```bash -cd crates/mtgc-server/deploy -cp .env.example .env # set MTGC_PUBLIC_URL, sign-in options -docker compose up -d -docker compose exec mtgc-server mtgc-server user add yourname +docker build -f crates/mtgc-server/Dockerfile -t mtgc-sync-server . + +docker run -d --name mtgc-sync-server \ + -p 127.0.0.1:8080:8080 \ + -v mtgc-sync-data:/data \ + -e MTGC_PUBLIC_URL=http://localhost:8080 \ + mtgc-sync-server + +docker exec -it mtgc-sync-server mtgc-server user add yourname # prompts for a password ``` +Then sign in from the desktop app with `http://localhost:8080`. For more +settings, copy `crates/mtgc-server/deploy/.env.example` to `.env` beside it +(git ignores it) and pass `--env-file crates/mtgc-server/deploy/.env` instead +of the `-e`. Podman takes the same commands. + +The container runs as UID 10001. A named volume, as above, picks up the right +ownership by itself; if you bind-mount a host directory instead, `chown 10001` +it first. + +In production, put it behind something that terminates HTTPS — the Android +app refuses plain HTTP outside debug builds — and set `MTGC_PUBLIC_URL` to +the `https://` address users reach it at. On Kubernetes that means a +Deployment with **one replica** (it's a single SQLite file, so replicas can't +share it) and a `Recreate` strategy, a PersistentVolumeClaim mounted at +`/data`, a Service on port 8080, and an Ingress for TLS. `GET /healthz` +answers `ok` for probes. Keep `MTGC_OIDC_CLIENT_SECRET` in a Secret. Run the +account commands with `kubectl exec -it deploy/ -- mtgc-server user add +`. + To build the image yourself and push it to your own registry: ```bash diff --git a/crates/mtgc-server/deploy/.env.example b/crates/mtgc-server/deploy/.env.example index f884f24..2895dbb 100644 --- a/crates/mtgc-server/deploy/.env.example +++ b/crates/mtgc-server/deploy/.env.example @@ -1,4 +1,9 @@ -# Where users reach the server, exactly as the reverse proxy serves it. +# Settings for the mtgc-server container, for `docker run --env-file`. Copy to +# .env (which git ignores) and fill in. See the README's "Running the sync +# server" for the full list. + +# Where users reach the server, exactly as they reach it: through your ingress +# in production, or the forwarded port locally (http://localhost:8080). MTGC_PUBLIC_URL=https://mtgc.example.com # Username/password accounts, managed with `mtgc-server user ...`. diff --git a/crates/mtgc-server/deploy/Caddyfile b/crates/mtgc-server/deploy/Caddyfile deleted file mode 100644 index b83e2ae..0000000 --- a/crates/mtgc-server/deploy/Caddyfile +++ /dev/null @@ -1,5 +0,0 @@ -# Replace with the hostname in MTGC_PUBLIC_URL. Caddy obtains the certificate. -mtgc.example.com { - encode gzip - reverse_proxy mtgc-server:8080 -} diff --git a/crates/mtgc-server/deploy/compose.yaml b/crates/mtgc-server/deploy/compose.yaml deleted file mode 100644 index c8b4c6d..0000000 --- a/crates/mtgc-server/deploy/compose.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Example deployment: mtgc-server behind Caddy, which provides HTTPS. -# -# cp .env.example .env # then fill it in -# docker compose up -d -# docker compose exec mtgc-server mtgc-server user add yourname - -services: - mtgc-server: - build: - context: ../../.. - dockerfile: crates/mtgc-server/Dockerfile - restart: unless-stopped - env_file: .env - volumes: - - mtgc-data:/data - - caddy: - image: caddy:2 - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy-data:/data - -volumes: - mtgc-data: - caddy-data: diff --git a/crates/mtgc-server/src/main.rs b/crates/mtgc-server/src/main.rs index 01e9caf..9308f64 100644 --- a/crates/mtgc-server/src/main.rs +++ b/crates/mtgc-server/src/main.rs @@ -9,7 +9,7 @@ //! ``` //! //! The `user` commands read the same `MTGC_DB` as the server, so in a container -//! they run with `docker compose exec mtgc-server mtgc-server user add me`. +//! they run with `docker exec -it mtgc-server user add me`. use std::io::{BufRead, IsTerminal}; @@ -63,15 +63,29 @@ fn serve() -> anyhow::Result<()> { .await .with_context(|| format!("binding {bind}"))?; tracing::info!("listening on {bind}"); - axum::serve(listener, router(state)) - .with_graceful_shutdown(async { - let _ = tokio::signal::ctrl_c().await; - }) - .await?; + axum::serve(listener, router(state)).with_graceful_shutdown(shutdown_signal()).await?; Ok(()) }) } +/// Resolves on Ctrl-C, or on SIGTERM — what `docker stop` and Kubernetes send. +/// As PID 1 in a container the process gets no default signal handling, so +/// without catching SIGTERM it would sit out the grace period and be killed. +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + } + #[cfg(not(unix))] + let _ = tokio::signal::ctrl_c().await; + tracing::info!("shutting down"); +} + /// Account management needs only the database, not the rest of the server's /// configuration, so it works before OIDC (say) is set up. fn open_db() -> anyhow::Result {