diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1444ce4..2bcb774 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,8 +17,9 @@ jobs:
- run: make all
# Self-hosting proof: push/clone the real repo against a gitmote container
- # backed by MinIO (scripts/e2e-local.sh). Gated — it builds an image and runs
- # Docker, so it stays off the fast per-PR path and runs on master / on demand.
+ # backed by MinIO (scripts/e2e-local.sh), plus the litestream cold-start restore
+ # (scripts/e2e-restore.sh). Gated — builds an image and runs Docker, so it stays
+ # off the fast per-PR path and runs on master / on demand.
e2e:
if: github.ref == 'refs/heads/master' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
@@ -27,3 +28,57 @@ jobs:
with:
fetch-depth: 0 # push needs the full history, not a shallow clone
- run: make e2e-local
+ - run: make e2e-restore
+
+ # Deploy the single writer to Scaleway on every green master push (docs/ops.md).
+ # max-scale=1 is asserted here, not left to a console default: two writers to the
+ # shared litestream WAL is the one forbidden state (safety.md §1).
+ deploy:
+ if: github.ref == 'refs/heads/master'
+ needs: [ci, e2e]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Login to Scaleway Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ secrets.SCW_REGISTRY_ENDPOINT }}
+ username: nologin
+ password: ${{ secrets.SCW_SECRET_KEY }}
+
+ - name: Build & push (amd64 — Scaleway requires it)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ VERSION=${{ github.sha }}
+ tags: |
+ ${{ secrets.SCW_REGISTRY_ENDPOINT }}/gitmote:master
+ ${{ secrets.SCW_REGISTRY_ENDPOINT }}/gitmote:${{ github.sha }}
+
+ - name: Install Scaleway CLI
+ run: |
+ SCW_URL=$(curl -s https://api.github.com/repos/scaleway/scaleway-cli/releases/latest \
+ | grep -o '"browser_download_url": *"[^"]*linux_amd64"' | cut -d'"' -f4)
+ sudo curl -fsSL "$SCW_URL" -o /usr/local/bin/scw
+ sudo chmod +x /usr/local/bin/scw
+
+ # A unique (SHA-tagged) image reference makes this a clean replacement, and
+ # max-scale=1 (the single-writer invariant) is re-asserted every deploy.
+ # min-scale=0 idles to zero between uses. --wait blocks until the new
+ # deployment is serving. See docs/ops.md for the single-writer deploy note.
+ - name: Deploy (single writer)
+ env:
+ SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
+ SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
+ SCW_DEFAULT_ORGANIZATION_ID: ${{ secrets.SCW_ORGANIZATION_ID }}
+ SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_PROJECT_ID }}
+ SCW_DEFAULT_REGION: fr-par
+ run: |
+ scw container container update ${{ secrets.SCW_CONTAINER_ID }} \
+ image=${{ secrets.SCW_REGISTRY_ENDPOINT }}/gitmote:${{ github.sha }} \
+ min-scale=0 max-scale=1 --wait
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fa05ac3..bc6038f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,6 +14,7 @@ docs/
architecture/ The design and its decisions — source of truth
notes/ Open design questions, one concern per file
evolution/ Speculative future directions — ideas, not commitments
+ ops.md Canonical infra doc — deploy, env, the single-writer rule
tasks/ The frontier — active/upcoming work; deleted once landed
```
diff --git a/Makefile b/Makefile
index bc85fcb..cc4f49d 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ GOIMPORTS := go run golang.org/x/tools/cmd/goimports@v0.47.0
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
-.PHONY: all fmt lint test build e2e-local
+.PHONY: all fmt lint test build e2e-local e2e-restore
all: lint test build
@@ -27,3 +27,9 @@ build:
# integration tests drive real git). Requires docker + docker compose.
e2e-local:
./scripts/e2e-local.sh
+
+# Litestream cold-start proof: push with metadata replication on, wipe the DB
+# volume, and confirm the repo still clones (refs restored from S3). De-risks the
+# production cold-start path locally. Requires docker + docker compose.
+e2e-restore:
+ ./scripts/e2e-restore.sh
diff --git a/README.md b/README.md
index 7865f03..f4dc147 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,10 @@ pushes this working tree, clones it back, and — after force-recreating the
container — clones again to prove the repo survives on the object store and
persisted refs (not local disk). Requires Docker + Docker Compose.
+`make e2e-restore` additionally exercises the litestream cold-start path (wipes
+the metadata DB and restores it from S3). Deployment — Scaleway Serverless
+Containers, single writer, `gitmote.atmin.net` — lives in [docs/ops.md](docs/ops.md).
+
## Bootstrap
An empty instance has no users, so token auth is a chicken-and-egg. Run the
diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index d0f8626..1546563 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -22,6 +22,7 @@ import (
"github.com/atmin/gitmote/internal/repo"
"github.com/atmin/gitmote/internal/store"
"github.com/atmin/gitmote/internal/webui"
+ "github.com/atmin/s3lite"
)
// version is stamped at build time via -ldflags "-X main.version=...".
@@ -52,8 +53,10 @@ func main() {
}
// runBootstrap creates the first admin, token, and repo from an empty instance.
-// It opens the metadata DB at GITMOTE_DB (local-only; litestream is wired in the
-// deploy task) and prints the one-time token to out on success.
+// It opens the metadata DB per the environment (GITMOTE_DB, and GITMOTE_DB_REPLICA
+// for litestream) and prints the one-time token to out on success. When a replica
+// is configured it Syncs before returning, so this short-lived process reliably
+// pushes the new admin/token/repo to S3 for the server to restore.
func runBootstrap(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
handle := fs.String("handle", os.Getenv("GITMOTE_ADMIN_HANDLE"), "admin user handle (or GITMOTE_ADMIN_HANDLE)")
@@ -63,7 +66,7 @@ func runBootstrap(ctx context.Context, args []string, out io.Writer) error {
return err
}
- md, err := meta.Open(ctx, meta.Config{LocalPath: envOr("GITMOTE_DB", "gitmote.sqlite3")})
+ md, err := meta.Open(ctx, metaConfigFromEnv(nil))
if err != nil {
return fmt.Errorf("open metadata: %w", err)
}
@@ -82,6 +85,13 @@ func runBootstrap(ctx context.Context, args []string, out io.Writer) error {
_, err := io.WriteString(out, "already bootstrapped: an admin exists; nothing to do\n")
return err
}
+
+ // Force the just-written admin/token/repo out to the replica before this
+ // short-lived process exits; Close alone does not guarantee an initial flush.
+ if err := md.Sync(ctx); err != nil {
+ return fmt.Errorf("replicate metadata: %w", err)
+ }
+
_, err = fmt.Fprintf(out,
"admin user: %s\n"+
"initial repo: %s\n\n"+
@@ -165,12 +175,11 @@ func newHandler(gitHandler http.Handler, ui *webui.Handler) http.Handler {
// HMAC key that signs session cookies); it shares this metadata handle, so it
// runs alongside the git server.
//
-// Metadata lives at GITMOTE_DB (default ./gitmote.sqlite3); materialized repos
-// cache under GITMOTE_CACHE (default a temp dir). The push path listens on
+// Metadata lives at GITMOTE_DB (default ./gitmote.sqlite3), replicated to
+// GITMOTE_DB_REPLICA when set (litestream; empty is local-only). Materialized
+// repos cache under GITMOTE_CACHE (default a temp dir). The push path listens on
// GITMOTE_SOCK (default a temp path) and installs the pre-receive hook binary
-// at GITMOTE_HOOK (default gitmote-hook alongside this binary). Litestream
-// replication of the metadata DB is wired in the deploy task (11); it is
-// local-only here.
+// at GITMOTE_HOOK (default gitmote-hook alongside this binary).
func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *webui.Handler, func() error, error) {
noop := func() error { return nil }
@@ -184,8 +193,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
return nil, nil, noop, fmt.Errorf("object store: %w", err)
}
- dbPath := envOr("GITMOTE_DB", "gitmote.sqlite3")
- md, err := meta.Open(ctx, meta.Config{LocalPath: dbPath})
+ md, err := meta.Open(ctx, metaConfigFromEnv(logger))
if err != nil {
return nil, nil, noop, fmt.Errorf("metadata: %w", err)
}
@@ -198,6 +206,15 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
}
cleanup := func() error {
err := writer.Close()
+ // Flush replication on clean shutdown so a redeploy/restart is lossless
+ // (a no-op without a replica). Bounded so an unreachable S3 can't hang
+ // shutdown; the accepted crash-loss window (safety.md §4) still covers a
+ // hard kill.
+ syncCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if serr := md.Sync(syncCtx); err == nil {
+ err = serr
+ }
if cerr := md.Close(); err == nil {
err = cerr
}
@@ -249,6 +266,23 @@ func hookBinaryPath() string {
return "gitmote-hook"
}
+// metaConfigFromEnv builds the metadata database config from the environment.
+// GITMOTE_DB_REPLICA is an s3:// URL used for both restore-on-cold-start and
+// continuous backup (litestream); empty leaves the database local-only (tests,
+// local dev), so the same binary runs unreplicated or replicated by env alone.
+// The replica reuses the object store's endpoint and the AWS default credential
+// chain, so one credential set covers both git objects and the metadata WAL.
+func metaConfigFromEnv(logger *slog.Logger) meta.Config {
+ replica := os.Getenv("GITMOTE_DB_REPLICA")
+ return meta.Config{
+ LocalPath: envOr("GITMOTE_DB", "gitmote.sqlite3"),
+ RestoreFrom: replica,
+ BackupTo: replica,
+ S3: s3lite.S3Config{Endpoint: os.Getenv("GITMOTE_S3_ENDPOINT")},
+ Logger: logger,
+ }
+}
+
// envOr returns the value of the environment variable key, or fallback if
// it is unset or empty.
func envOr(key, fallback string) string {
diff --git a/docker-compose.yml b/docker-compose.yml
index 58b481b..9b49bda 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -41,6 +41,10 @@ services:
GITMOTE_DB: /data/meta.sqlite3 # persistent (named volume) — refs survive restart
GITMOTE_CACHE: /cache # ephemeral — materialized repos rebuild from S3
GITMOTE_SOCK: /tmp/gitmote.sock
+ # Opt-in litestream replication of the metadata DB. Empty by default
+ # (e2e-local proves volume durability); e2e-restore.sh sets it to exercise
+ # restore-from-S3 after the DB volume is wiped — the deploy cold-start path.
+ GITMOTE_DB_REPLICA: ${GITMOTE_DB_REPLICA:-}
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
diff --git a/docs/ops.md b/docs/ops.md
new file mode 100644
index 0000000..fb25003
--- /dev/null
+++ b/docs/ops.md
@@ -0,0 +1,193 @@
+# Operations
+
+Canonical infra doc — resources, env, deploy, and the one operational rule. Keep
+it current whenever infra or env changes.
+
+gitmote runs on the same account and host as atmin.net (see that repo's
+`docs/ops.md` for the Scaleway pattern in more depth); this doc is gitmote-specific.
+
+## Infrastructure
+
+| Component | Provider | Product |
+|-----------|----------|---------|
+| Compute | Scaleway | Serverless Containers (`fr-par`, custom domain via CNAME, auto TLS) |
+| Object storage | Scaleway | Object Storage (S3-compatible), bucket `gitmote` |
+| Registry | Scaleway | Container Registry (`rg.fr-par.scw.cloud/atmin`, shared with atmin.net) |
+
+Reachable at **`gitmote.atmin.net`** (CNAME → the container endpoint). EU-resident
+infrastructure, consistent with atmin.net's stance.
+
+One bucket, two prefixes: `objects/` (immutable git objects/packs) and `meta/`
+(the litestream WAL of the metadata SQLite). Refs are the source of truth and
+live in the metadata DB; objects live under `objects/`.
+
+## ⚠️ Single writer is a correctness invariant
+
+[safety.md §1](architecture/safety.md) — **never two writer instances.** gitmote
+replicates its metadata SQLite to S3 with litestream; two instances writing the
+same WAL can corrupt the replica. This is stronger than a stateless service's
+in-process-race concern: it is a data-integrity precondition.
+
+- **`max-scale = 1`** on the container, asserted on every deploy (the CI deploy
+ step sets it; it is not left to a console default). Autoscaling above 1 opens
+ the forbidden state. `min-scale = 0` (idle to zero) is fine — it only changes
+ cost/latency, not the invariant; `max-scale = 1` is what guards it.
+- **No two instances during a transition.** Each deploy pushes a uniquely
+ (SHA-)tagged image and `update … --wait`s for the new deployment to serve. With
+ `max-scale = 1` Scaleway should replace the single instance rather than run two;
+ the same must hold when waking from zero. **Confirm on the first deploy and the
+ first wake-from-idle** (watch `scw container container get` — instance count
+ must not exceed 1). If Scaleway is ever seen to overlap, switch the deploy to
+ scale the container to zero, wait for termination, then bring up the new image.
+- Because pushes are operator-initiated and infrequent, deploy while no push is
+ in flight. The content-before-pointer ordering ([safety.md §3](architecture/safety.md))
+ bounds any residual window to orphan objects (garbage `gc` reclaims), never a
+ ref pointing at a missing object.
+
+Note: Scaleway Object Storage has **no conditional writes** (`If-Match` /
+`If-None-Match`). This does **not** affect gitmote — the ref CAS is a SQL
+transaction in the metadata DB, not an S3 precondition. (It does rule out the
+safety.md §4 escape hatch of moving refs to a conditional-PUT on S3 *while on
+Scaleway*; that would need a provider with preconditions.)
+
+## Runtime env vars
+
+Set on the Scaleway Serverless Container (secrets as
+`secret-environment-variables`):
+
+| Variable | Value / meaning |
+|----------|-----------------|
+| `GITMOTE_ADDR` | bind address — `:8080` (Scaleway routes to `port=8080`) |
+| `GITMOTE_S3_BUCKET` | `gitmote` |
+| `GITMOTE_S3_ENDPOINT` | `https://s3.fr-par.scw.cloud` |
+| `GITMOTE_S3_PREFIX` | `objects/` — git objects live under this prefix |
+| `GITMOTE_DB_REPLICA` | `s3://gitmote/meta` — litestream restore + backup target |
+| `GITMOTE_DB` | `/tmp/gitmote/meta.sqlite3` — ephemeral; restored from the replica on cold start |
+| `GITMOTE_CACHE` | `/tmp/gitmote/cache` — ephemeral; materialized repos rebuild from S3 |
+| `GITMOTE_SOCK` | `/tmp/gitmote/gitmote.sock` — pre-receive hook RPC socket |
+| `GITMOTE_COOKIE_KEY` | secret — signs management-UI session cookies (enables `/ui`) |
+| `AWS_REGION` | `fr-par` |
+| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | secret — Scaleway API key pair; covers both the object store and the litestream replica |
+
+`GITMOTE_DB` and `GITMOTE_CACHE` are ephemeral on purpose: the object store +
+litestream replica are the durable state, and the local disk is a cache. On a
+cold start (scale-to-zero → wake, or a redeploy) the DB is restored from
+`GITMOTE_DB_REPLICA` and repos re-materialize from `objects/`. This path is
+proven locally by `make e2e-restore` (wipes the DB volume, confirms the repo
+still clones).
+
+Memory: **1 GB** (`memory-limit-bytes=1GB` — the CLI requires a `G`/`GB` unit).
+`git index-pack` / `receive-pack` need far more than a stateless signer; 1 GB
+gives headroom and, at `min-scale=0`, is idle-billed. Tune from logs.
+
+Scale: **`min-scale = 0`** (idle to zero). A remote pushed to occasionally is idle
+almost always, and always-on at 512 MB exceeds Scaleway's free tier (~1.3M GB-s/mo
+vs 400k), so scale-to-zero is the cheaper default. The cost is a few seconds of
+cold start on the first request after idle (restore + materialize, the path above);
+scale-down is a graceful SIGTERM, so the shutdown `Sync` flushes the WAL — no lost
+writes. Set `min-scale = 1` only if instant first-push latency is worth the
+always-on cost. `max-scale` stays **1** regardless (single-writer invariant).
+
+## One-time Scaleway setup
+
+```bash
+# 1. Object Storage bucket (console or CLI), fr-par, name "gitmote".
+# Generate an API key pair for it.
+
+# 2. Push the initial image (registry namespace "atmin" is shared with atmin.net;
+# Scaleway requires amd64 — build with --platform on ARM Macs).
+docker login rg.fr-par.scw.cloud/atmin -u nologin -p <SCW_SECRET_KEY>
+docker build --platform=linux/amd64 -t rg.fr-par.scw.cloud/atmin/gitmote:master .
+docker push rg.fr-par.scw.cloud/atmin/gitmote:master
+
+# 3. Create the container (single writer: min-scale=0 idle-to-zero, max-scale=1).
+# Arg names per the current CLI: image= (not registry-image), memory-limit-bytes,
+# and key-based (secret-)environment-variables.KEY=value. Only credentials and
+# the cookie key are secret; the rest are plain environment-variables.
+scw container container create \
+ namespace-id=<NAMESPACE_ID> \
+ name=gitmote \
+ image=rg.fr-par.scw.cloud/atmin/gitmote:master \
+ min-scale=0 max-scale=1 \
+ memory-limit-bytes=1GB \
+ port=8080 \
+ environment-variables.GITMOTE_S3_BUCKET=gitmote \
+ environment-variables.GITMOTE_S3_PREFIX=objects/ \
+ environment-variables.GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \
+ environment-variables.GITMOTE_DB_REPLICA=s3://gitmote/meta \
+ environment-variables.GITMOTE_DB=/tmp/gitmote/meta.sqlite3 \
+ environment-variables.GITMOTE_CACHE=/tmp/gitmote/cache \
+ environment-variables.GITMOTE_SOCK=/tmp/gitmote/gitmote.sock \
+ environment-variables.AWS_REGION=fr-par \
+ secret-environment-variables.GITMOTE_COOKIE_KEY="$(openssl rand -base64 32)" \
+ secret-environment-variables.AWS_ACCESS_KEY_ID=<KEY> \
+ secret-environment-variables.AWS_SECRET_ACCESS_KEY=<SECRET>
+
+# 4. Custom domain — two parts:
+# (a) DNS: add a CNAME gitmote.atmin.net → <container-endpoint>.scw.cloud
+# (b) Register the domain ON the container so Scaleway issues a managed cert
+# (the CNAME alone is not enough — until this, HTTPS serves the endpoint's
+# own cert and browsers report "not secure"):
+scw container domain create container-id=<CONTAINER_ID> hostname=gitmote.atmin.net
+scw container domain list container-id=<CONTAINER_ID> # status pending → ready (minutes)
+# Note: gitmote has no route at "/", so http(s)://gitmote.atmin.net/ returns 404
+# by design; check /healthz for liveness. Git lives under /<owner>/<repo>/….
+
+# 5. GitHub secrets for the deploy pipeline (same set as atmin.net):
+# SCW_ACCESS_KEY, SCW_SECRET_KEY, SCW_ORGANIZATION_ID, SCW_PROJECT_ID,
+# SCW_REGISTRY_ENDPOINT (rg.fr-par.scw.cloud/atmin), SCW_CONTAINER_ID.
+```
+
+## Bootstrap (run once, before the server is live)
+
+An empty instance has no admin/token. Bootstrap **from your machine against the
+prod bucket** so you are transiently the single writer — do this *before* the
+container serves traffic (or while it is scaled to zero), never as a job while
+the server runs, or that is two writers.
+
+```bash
+GITMOTE_DB=/tmp/bootstrap.sqlite3 \
+GITMOTE_DB_REPLICA=s3://gitmote/meta \
+GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud \
+AWS_REGION=fr-par AWS_ACCESS_KEY_ID=<KEY> AWS_SECRET_ACCESS_KEY=<SECRET> \
+ gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch master
+```
+
+It prints the access token once (save it) and `Sync`s the new state to
+`s3://gitmote/meta`; the server restores it on first start. Re-running is safe —
+it refuses to clobber an existing admin.
+
+## Deploying
+
+Automatic on every green push to `master` (`.github/workflows/ci.yml`, `deploy`
+job): `ci` (lint/test/build) → `e2e` (local push/clone + litestream restore) →
+build+push the amd64 image → `scw container container update … min-scale=0
+max-scale=1 --wait`.
+
+```bash
+git push origin master
+```
+
+## Verifying it hosts itself
+
+```bash
+# Push the gitmote repo over HTTPS to the deployed instance:
+git push https://atmin:<token>@gitmote.atmin.net/atmin/gitmote HEAD:refs/heads/master
+git clone https://atmin:<token>@gitmote.atmin.net/atmin/gitmote /tmp/gitmote-clone
+git -C /tmp/gitmote-clone fsck --full # clean
+```
+
+Cold-start check: trigger a redeploy (or let it idle to zero and wake it), then
+clone again — the metadata restores from the replica and the repo serves. The
+same path is exercised locally by `make e2e-restore`.
+
+## Troubleshooting
+
+```bash
+scw container container get <CONTAINER_ID> # status, error messages, instance count
+scw container container redeploy <CONTAINER_ID> # re-pull the current image tag
+scw registry image list # images in the registry
+```
+
+Logs flow to Scaleway Cockpit (console → Observability → Cockpit → Grafana →
+Explore → Loki). gitmote logs JSON to stderr.
diff --git a/scripts/e2e-restore.sh b/scripts/e2e-restore.sh
new file mode 100755
index 0000000..a2defe5
--- /dev/null
+++ b/scripts/e2e-restore.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# Prove the litestream cold-start path before trusting it in production: push a
+# repo with metadata replication on, gracefully stop the server (flushing the WAL
+# to S3), then DELETE the metadata volume — simulating a fresh container on
+# ephemeral disk — and bring it back. The clone must still succeed, which is only
+# possible if refs were restored from the object store, not local disk.
+set -euo pipefail
+
+DC="docker compose"
+ROOT="$(git rev-parse --show-toplevel)"
+BRANCH="$(git -C "$ROOT" symbolic-ref --quiet --short HEAD || echo master)"
+PUSHED="$(git -C "$ROOT" rev-parse HEAD)"
+
+# Turn on replication for this run (docker-compose.yml reads it; empty otherwise).
+export GITMOTE_DB_REPLICA="s3://gitmote/meta"
+DB_VOLUME="gitmote-e2e_gitmote-data"
+
+export GIT_TERMINAL_PROMPT=0
+GITC=(git -c credential.helper= -c http.postBuffer=524288000)
+
+CLONES=()
+cleanup() {
+ local code=$?
+ echo "--- tearing down ---"
+ ( cd "$ROOT" && $DC down -v --remove-orphans ) || true
+ for d in "${CLONES[@]:-}"; do [ -n "$d" ] && rm -rf "$d"; done
+ exit $code
+}
+trap cleanup EXIT
+
+fail() { echo "RESTORE FAIL: $*" >&2; exit 1; }
+
+wait_healthz() {
+ for _ in $(seq 1 60); do
+ curl -fsS http://localhost:8080/healthz >/dev/null 2>&1 && return 0
+ sleep 1
+ done
+ ( cd "$ROOT" && $DC logs gitmote ) || true
+ fail "gitmote did not become healthy"
+}
+
+cd "$ROOT"
+
+echo "--- build + minio + bucket ---"
+$DC build gitmote
+$DC up -d minio
+$DC run --rm mc
+
+echo "--- bootstrap (replicates initial metadata to S3) ---"
+BOOT="$($DC run --rm -T gitmote bootstrap -handle atmin -repo atmin/gitmote -default-branch "$BRANCH")"
+TOKEN="$(printf '%s\n' "$BOOT" | grep -oE 'gmt_[0-9a-f]+\.[0-9a-f]+' | head -1)"
+[ -n "$TOKEN" ] || { printf '%s\n' "$BOOT"; fail "no token in bootstrap output"; }
+REPO_URL="http://atmin:${TOKEN}@localhost:8080/atmin/gitmote"
+
+echo "--- start server + push ($BRANCH @ $PUSHED) ---"
+$DC up -d gitmote
+wait_healthz
+"${GITC[@]}" -C "$ROOT" push "$REPO_URL" "HEAD:refs/heads/${BRANCH}" || fail "push failed"
+
+echo "--- graceful stop (flushes WAL to S3), then DESTROY the metadata volume ---"
+$DC stop gitmote # SIGTERM → graceful shutdown → replication flush
+$DC rm -f gitmote # remove the container so the volume is free
+docker volume rm "$DB_VOLUME" # simulate a fresh container: local DB is gone
+docker volume ls --format '{{.Name}}' | grep -qx "$DB_VOLUME" \
+ && fail "metadata volume still present — wipe did not take"
+
+echo "--- cold start: DB restored from S3, cache rebuilt from S3 ---"
+$DC up -d gitmote
+wait_healthz
+
+clone="$(mktemp -d)"; CLONES+=("$clone")
+"${GITC[@]}" clone "$REPO_URL" "$clone" || fail "clone after restore failed"
+cloned="$(git -C "$clone" rev-parse HEAD)"
+[ "$cloned" = "$PUSHED" ] || fail "restored HEAD $cloned != pushed $PUSHED"
+git -C "$clone" fsck --full --strict || fail "restored repo fsck not clean"
+diff <(git -C "$ROOT" ls-tree -r "$PUSHED") <(git -C "$clone" ls-tree -r HEAD) \
+ || fail "restored file tree differs from pushed commit"
+
+echo "RESTORE PASS: metadata volume wiped, refs restored from S3 — HEAD=$cloned, fsck clean, tree identical."
diff --git a/tasks/11-deploy.md b/tasks/11-deploy.md
index 7cec4d4..7198ed1 100644
--- a/tasks/11-deploy.md
+++ b/tasks/11-deploy.md
@@ -8,25 +8,109 @@ Run a single-writer gitmote against real object storage so the gitmote repo
genuinely lives on it. Honor the one operational rule — **never two writer
instances** ([safety.md](../docs/architecture/safety.md) §1).
+Target (chosen): **Scaleway Serverless Containers + Scaleway Object Storage**,
+`fr-par`, reachable at `gitmote.atmin.net` (CNAME → the container endpoint, auto
+TLS). Same host and account as atmin.net — reuse its registry (`rg.fr-par.scw.cloud/atmin`)
+and container namespace. See atmin.net's `docs/ops.md` for the pattern.
+
## Current
-Only local (MinIO) hosting exists (task 10).
+Only local (MinIO) hosting exists (task 10). `meta.Config` already exposes
+`RestoreFrom` / `BackupTo` / `S3`, but `cmd/gitmote/main.go` passes only
+`LocalPath` on both the server and `bootstrap` paths — litestream is unwired.
## Change
-- Provision real S3-compatible storage + a single-instance runtime; no
- overlapping deploys of the writer (document the rule and enforce it in the
- deploy config).
-- Point s3lite's litestream WAL target at the real bucket; verify restore on
- cold start.
-- TLS terminated at the edge; config/secrets via env.
-- `docs/ops.md` — the canonical infra doc: resources, env vars, deploy commands,
- the single-writer rule. Keep it updated whenever infra/env changes.
+### 1. Wire the metadata replica (the only Go change)
+
+`cmd/gitmote/main.go`: a `metaConfigFromEnv()` helper builds `meta.Config` from
+env and is used by **both** `buildGitHandler` and `runBootstrap` (today they each
+inline `meta.Config{LocalPath: …}`):
+
+- `LocalPath` ← `GITMOTE_DB`
+- `RestoreFrom` = `BackupTo` ← `GITMOTE_DB_REPLICA` (e.g. `s3://gitmote/meta`);
+ **empty ⇒ local-only**, so task-10 `make e2e-local` is unchanged.
+- `S3.Endpoint` ← `GITMOTE_S3_ENDPOINT`; region/credentials fall through to the
+ AWS default chain (`AWS_REGION`, `AWS_ACCESS_KEY_ID`, …) — the same source the
+ object store already uses, so one credential set covers objects **and** WAL.
+
+`RestoreFrom` is ignored when `LocalPath` exists, so a warm instance keeps
+replicating and a cold instance (fresh ephemeral disk) restores — exactly the
+scale-to-zero → wake path.
+
+### 2. De-risk litestream locally before trusting Scaleway
+
+Task 10 never exercised litestream (local-only DB on a volume). Add a
+`make e2e-restore` (or extend `scripts/e2e-local.sh`) that sets
+`GITMOTE_DB_REPLICA=s3://gitmote/meta` against MinIO, pushes, then **wipes the DB
+volume *and* recreates the container**, and asserts the clone still succeeds —
+proving the refs came back from S3, not disk. This makes the Scaleway cold-start
+verify a confirmation, not a first attempt.
+
+### 3. Image & registry
+
+Dockerfile is arch-agnostic; Scaleway needs `linux/amd64`, so build with
+`--platform=linux/amd64` (documented, done in CI). Push to
+`rg.fr-par.scw.cloud/atmin/gitmote`.
+
+### 4. Single-writer enforcement (the crux)
+
+- **`max-scale=1`** on the container — a correctness precondition (litestream to
+ a shared WAL from two writers can corrupt the replica; this is stronger than
+ atmin.net's stateless case). Assert the value at deploy time, not a console
+ default.
+- **No deploy overlap.** Scaleway's default rollout can briefly run old+new = two
+ writers. Investigate whether a stop-before-start strategy exists; if not, the
+ deploy **scales to 0, waits for termination, updates the image, scales back to
+ 1** — a few seconds of downtime traded for the invariant. This is acceptable
+ for a personal git host and is the safe default.
+- Cross-link the rule from `docs/ops.md` to safety.md §1.
+
+### 5. Bootstrap without a second writer
+
+Run `gitmote bootstrap` **once from the operator's machine** with
+`GITMOTE_DB_REPLICA` pointed at the prod bucket, **before** the server scales up
+(the operator is transiently the single writer; it writes + flushes replication
+on `Close`, the server then restores). Documented in ops.md — do **not** run it
+as a job while the server is live.
+
+### 6. Runtime env / secrets (Scaleway container)
+
+`GITMOTE_S3_BUCKET=gitmote`, `GITMOTE_S3_ENDPOINT=https://s3.fr-par.scw.cloud`,
+`GITMOTE_S3_PREFIX=objects/`, `GITMOTE_DB_REPLICA=s3://gitmote/meta`,
+`GITMOTE_DB=/tmp/gitmote/meta.sqlite3`, `GITMOTE_CACHE=/tmp/gitmote/cache`,
+`GITMOTE_SOCK=/tmp/gitmote/gitmote.sock`, `GITMOTE_COOKIE_KEY=<secret>` (UI),
+`AWS_REGION=fr-par`, `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY=<Scaleway keys>`
+(secret). Memory: start ~**512 MB** (git `index-pack`/`receive-pack` need far
+more than atmin.net's 128 MB) and tune from logs; ensure ephemeral scratch for
+materialization.
+
+### 7. CI deploy pipeline
+
+Minimal `deploy.yml`: on every green **master push**, build+push the amd64 image
+and redeploy the single prod container (`gitmote.atmin.net`) via the
+single-writer-safe strategy above, asserting `max-scale=1`. No staging, no tags —
+one container, one bucket, one writer. GitHub secrets `SCW_*` as in atmin.net.
+
+### 8. Docs
+
+- **New `docs/ops.md`** — canonical infra doc for gitmote: infra table, the
+ single-writer invariant (link safety.md §1) + the deploy strategy, env-var
+ table, one-time Scaleway setup (`scw` commands, bucket, CNAME), bootstrap
+ ordering, cold-start restore, troubleshooting. Note the EU-resident stance and
+ that Scaleway's missing conditional-writes don't affect gitmote (CAS is in
+ s3lite, not S3).
+- `docs/architecture/storage.md` stays provider-agnostic; the litestream target
+ being real is captured in ops.md.
## Verify
-- Bootstrap on the deployed instance; `git push` the gitmote repo over HTTPS and
- `git clone` it back — the repo lives there.
-- Cold start (scale-to-zero → wake) restores metadata and serves the repo.
-- The deploy pipeline never runs two writers concurrently.
+- `make e2e-restore` green locally (litestream restore after wiping the DB).
+- On the deployed instance: `gitmote bootstrap`, then `git push` the gitmote repo
+ over **HTTPS** to `gitmote.atmin.net` and `git clone` it back — the repo lives
+ there.
+- Cold start (scale-to-zero → wake, or a redeploy) restores metadata and serves
+ the repo.
+- The deploy pipeline demonstrably never runs two writers concurrently
+ (stop-then-start; `max-scale=1` asserted).
- Milestone: **gitmote hosts itself.**