From 37d81f37130fd19a7bc535617680542c0e2cc96b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 20 Aug 2026 19:23:42 +0000 Subject: [PATCH 1/5] feat(deploy): add portable container contract Production deployment previously depended on an illustrative Docker snippet and did not define which state or identity must survive replacement. Add a non-root runtime image, loopback-only Compose service, optional Caddy TLS overlay, shared /data layout, bounded public verifier, and an identity-persistence container test. Document backup, proxy, single-writer, upgrade, and rollback requirements as the contract for every environment. This assumes one ngit-grasp writer per state directory and a reverse proxy or platform edge for public TLS. Image publication and provider-specific control-plane setup are deliberately left to separate changes. Validated with sh -n, ShellCheck 0.11.0, locked Cargo metadata, YAML parsing, Docker Hub tag lookups, local input-failure checks, and git diff --check. Docker/Podman is unavailable in this VM, so the included end-to-end container test was not run here. --- .dockerignore | 11 ++ Dockerfile | 55 ++++++++++ compose.caddy.yaml | 28 ++++++ compose.yaml | 46 +++++++++ deploy.env.example | 21 ++++ deploy/Caddyfile | 3 + deploy/docker-entrypoint.sh | 38 +++++++ docs/how-to/deploy-docker.md | 136 +++++++++++++++++++++++++ docs/reference/deployment-contract.md | 139 ++++++++++++++++++++++++++ scripts/test-container-deployment.sh | 95 ++++++++++++++++++ scripts/verify-deployment.sh | 99 ++++++++++++++++++ 11 files changed, 671 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.caddy.yaml create mode 100644 compose.yaml create mode 100644 deploy.env.example create mode 100644 deploy/Caddyfile create mode 100755 deploy/docker-entrypoint.sh create mode 100644 docs/how-to/deploy-docker.md create mode 100644 docs/reference/deployment-contract.md create mode 100755 scripts/test-container-deployment.sh create mode 100755 scripts/verify-deployment.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6bd3581 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.direnv +.env +.relay-owner.nsec +data +result +result-* +target +work +worktrees +**/target diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbe1004 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 + +FROM rust:1.96-bookworm AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . . + +ARG NGIT_BUILD_REVISION=unknown +ENV NGIT_BUILD_REVISION=${NGIT_BUILD_REVISION} + +RUN cargo build --locked --release -p ngit-grasp + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gosu \ + libssl3 \ + tini \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 ngit-grasp \ + && useradd --system --uid 10001 --gid ngit-grasp \ + --home-dir /data --no-create-home ngit-grasp \ + && install -d -m 0750 -o ngit-grasp -g ngit-grasp \ + /data /data/git /data/relay + +COPY --from=builder /src/target/release/ngit-grasp /usr/local/bin/ngit-grasp +COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + +ARG NGIT_IMAGE_VERSION=dev +ARG NGIT_IMAGE_REVISION=unknown +LABEL org.opencontainers.image.title="ngit-grasp" \ + org.opencontainers.image.description="GRASP relay and Git Smart HTTP server" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.source="https://gitnostr.com/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git" \ + org.opencontainers.image.version="${NGIT_IMAGE_VERSION}" \ + org.opencontainers.image.revision="${NGIT_IMAGE_REVISION}" + +ENV HOME=/data \ + NGIT_GIT_DATA_PATH=/data/git \ + NGIT_RELAY_DATA_PATH=/data/relay + +WORKDIR /data +VOLUME ["/data"] +EXPOSE 7334 +STOPSIGNAL SIGTERM + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] diff --git a/compose.caddy.yaml b/compose.caddy.yaml new file mode 100644 index 0000000..b7b05d8 --- /dev/null +++ b/compose.caddy.yaml @@ -0,0 +1,28 @@ +services: + ngit-grasp: + environment: + NGIT_TRUSTED_PROXY_CIDRS: "${NGIT_CADDY_IP:-172.30.73.2}/32" + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + ngit-grasp: + condition: service_healthy + environment: + NGIT_DOMAIN: "${NGIT_DOMAIN:?copy deploy.env.example to .env and set NGIT_DOMAIN}" + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + networks: + backend: + ipv4_address: "${NGIT_CADDY_IP:-172.30.73.2}" + +volumes: + caddy-data: + caddy-config: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..faa3beb --- /dev/null +++ b/compose.yaml @@ -0,0 +1,46 @@ +name: ngit-grasp + +services: + ngit-grasp: + build: + context: . + args: + NGIT_BUILD_REVISION: "${NGIT_BUILD_REVISION:-unknown}" + NGIT_IMAGE_REVISION: "${NGIT_IMAGE_REVISION:-unknown}" + NGIT_IMAGE_VERSION: "${NGIT_IMAGE_VERSION:-dev}" + image: "${NGIT_IMAGE:-ngit-grasp:local}" + restart: unless-stopped + env_file: + - path: ./.env + required: false + environment: + NGIT_DOMAIN: "${NGIT_DOMAIN:?copy deploy.env.example to .env and set NGIT_DOMAIN}" + NGIT_BASE_PATH: "${NGIT_BASE_PATH:-/}" + NGIT_LOG_LEVEL: "${NGIT_LOG_LEVEL:-info}" + ports: + - "127.0.0.1:${NGIT_HOST_PORT:-7334}:7334" + volumes: + - ngit-grasp-data:/data + networks: + - backend + stop_grace_period: 5m + healthcheck: + test: + - CMD-SHELL + - >- + curl --fail --silent --show-error + --header 'Accept: application/nostr+json' + "http://127.0.0.1:7334$${NGIT_BASE_PATH:-/}" >/dev/null + interval: 30s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + ngit-grasp-data: + +networks: + backend: + ipam: + config: + - subnet: "${NGIT_COMPOSE_SUBNET:-172.30.73.0/24}" diff --git a/deploy.env.example b/deploy.env.example new file mode 100644 index 0000000..ce6b34a --- /dev/null +++ b/deploy.env.example @@ -0,0 +1,21 @@ +# Required: the public hostname, without a URL scheme or path. +NGIT_DOMAIN=ngit.example.com + +# Public mount path. The bundled Caddy stack expects a domain-root service. +NGIT_BASE_PATH=/ + +# Loopback port used by compose.yaml when an existing host proxy is used. +NGIT_HOST_PORT=7334 + +# Application logging filter. +NGIT_LOG_LEVEL=info + +# Optional initial relay. Sync discovers additional relays automatically. +# NGIT_SYNC_BOOTSTRAP_RELAY_URL=wss://relay.ngit.dev + +# Compose network settings. Change both only if the default subnet conflicts. +NGIT_COMPOSE_SUBNET=172.30.73.0/24 +NGIT_CADDY_IP=172.30.73.2 + +# Do not put NGIT_RELAY_OWNER_NSEC here. By default the service generates +# /data/.relay-owner.nsec in the persistent volume. Back up that volume. diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..92f7fa6 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,3 @@ +{$NGIT_DOMAIN} { + reverse_proxy ngit-grasp:7334 +} diff --git a/deploy/docker-entrypoint.sh b/deploy/docker-entrypoint.sh new file mode 100755 index 0000000..950ace4 --- /dev/null +++ b/deploy/docker-entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +data_root=/data +git_data_path=${NGIT_GIT_DATA_PATH:-${data_root}/git} +relay_data_path=${NGIT_RELAY_DATA_PATH:-${data_root}/relay} + +if [ -z "${NGIT_BIND_ADDRESS:-}" ]; then + public_port=${PORT:-7334} + case "${public_port}" in + ''|*[!0-9]*) + echo "PORT must be an integer" >&2 + exit 2 + ;; + esac + if [ "${public_port}" -lt 1 ] || [ "${public_port}" -gt 65535 ]; then + echo "PORT must be between 1 and 65535" >&2 + exit 2 + fi + export NGIT_BIND_ADDRESS="0.0.0.0:${public_port}" +fi + +export NGIT_GIT_DATA_PATH="${git_data_path}" +export NGIT_RELAY_DATA_PATH="${relay_data_path}" + +if [ "$(id -u)" -eq 0 ]; then + install -d -m 0750 -o ngit-grasp -g ngit-grasp \ + "${data_root}" "${git_data_path}" "${relay_data_path}" + + if [ -e "${data_root}/.relay-owner.nsec" ]; then + chown ngit-grasp:ngit-grasp "${data_root}/.relay-owner.nsec" + chmod 0600 "${data_root}/.relay-owner.nsec" + fi + + exec gosu ngit-grasp:ngit-grasp ngit-grasp "$@" +fi + +exec ngit-grasp "$@" diff --git a/docs/how-to/deploy-docker.md b/docs/how-to/deploy-docker.md new file mode 100644 index 0000000..a1ab3e6 --- /dev/null +++ b/docs/how-to/deploy-docker.md @@ -0,0 +1,136 @@ +# Deploy with Docker or Podman + +This is the portable default for a Linux host. The repository ships a +multi-stage `Dockerfile`, a loopback-only Compose service, and an optional +Caddy overlay for automatic HTTPS. + +The image contains ngit-grasp, Git, CA certificates, and a small init process. +It prepares `/data` and then runs ngit-grasp as UID/GID `10001`. + +## Prerequisites + +- Docker Engine with Compose v2, or a compatible Podman Compose setup +- durable local storage for the named volume +- a domain whose DNS points to the host +- ports 80 and 443 available when using bundled Caddy + +Review the [deployment contract](../reference/deployment-contract.md) before +placing the state volume on remote or managed storage. + +## Fresh VPS with automatic HTTPS + +Create the small deployment environment file: + +```bash +cp deploy.env.example .env +``` + +Set `NGIT_DOMAIN` in `.env`, then start the relay and Caddy: + +```bash +docker compose -f compose.yaml -f compose.caddy.yaml up --build -d +docker compose -f compose.yaml -f compose.caddy.yaml ps +scripts/verify-deployment.sh https://ngit.example.com +``` + +Caddy obtains and renews the certificate. The relay is also published on +`127.0.0.1:7334` for local diagnostics, but it is not directly reachable from +the network. + +The bundled Caddy configuration serves ngit-grasp at the domain root. To share +a hostname using `NGIT_BASE_PATH`, use an existing reverse proxy and configure +its path routing explicitly. + +## Existing reverse proxy + +Start only the relay service: + +```bash +docker compose up --build -d +curl -H 'Accept: application/nostr+json' http://127.0.0.1:7334 +``` + +Proxy the public HTTPS hostname to `http://127.0.0.1:7334`. Preserve WebSocket +upgrades, request methods, bodies, and query strings. Forwarded client IP +headers are ignored by default; configure `NGIT_TRUSTED_PROXY_CIDRS` only after +identifying the exact container-visible proxy address and keeping the backend +private. + +## State and identity + +The `ngit-grasp-data` named volume is mounted at `/data` and contains all +durable state. The first start creates `/data/.relay-owner.nsec`; retaining the +volume retains the public identity advertised in NIP-11. + +Inspect the resolved volume without guessing its Compose prefix: + +```bash +docker volume inspect ngit-grasp_ngit-grasp-data +``` + +Do not use `docker compose down --volumes` in production. It deletes the relay +identity, events, and Git repositories. + +## Operations + +```bash +# Logs +docker compose logs --follow ngit-grasp + +# Controlled restart +docker compose restart ngit-grasp + +# Stop while retaining state +docker compose stop + +# Start again +docker compose start +``` + +When the Caddy overlay is active, include both `-f` arguments for commands that +must operate on the complete project. + +## Backup + +Stop the writer, archive the complete volume, and start it again: + +```bash +docker compose stop ngit-grasp +docker run --rm \ + --volume ngit-grasp_ngit-grasp-data:/data:ro \ + --volume "$PWD:/backup" \ + alpine:3.22 \ + tar -czf /backup/ngit-grasp-data.tar.gz -C /data . +docker compose start ngit-grasp +``` + +Store the archive away from the Docker host. Test restoration into a separate, +non-public deployment before relying on it. + +## Upgrade + +Pin or check out the intended tag, read `CHANGELOG.md`, take a backup, and +rebuild without overlapping the old and new writers: + +```bash +docker compose stop ngit-grasp +docker compose build --pull ngit-grasp +docker compose up -d ngit-grasp +scripts/verify-deployment.sh https://ngit.example.com +``` + +For storage-changing releases, follow the linked migration guide and restore +the pre-upgrade volume snapshot before attempting a binary rollback. + +## Validate the image locally + +The repository includes a destructive-to-test-resources-only integration +check. It builds the image, creates uniquely named temporary container and +volume resources, replaces the container, and confirms the NIP-11 pubkey did +not change: + +```bash +scripts/test-container-deployment.sh +``` + +Set `CONTAINER_ENGINE=podman` to exercise a compatible Podman CLI. diff --git a/docs/reference/deployment-contract.md b/docs/reference/deployment-contract.md new file mode 100644 index 0000000..f703082 --- /dev/null +++ b/docs/reference/deployment-contract.md @@ -0,0 +1,139 @@ +# Deployment contract + +This reference defines the runtime assumptions shared by every supported +ngit-grasp deployment. Environment-specific guides should provide these +properties rather than inventing a different layout. + +## Process + +ngit-grasp is one long-running process. It serves Git Smart HTTP, Nostr +WebSockets, NIP-11, landing pages, and Prometheus metrics from one HTTP port. +The process must: + +- have `git` and trusted CA certificates available at runtime; +- receive `SIGTERM` during a controlled stop; +- have enough shutdown time to persist state and clean up temporary refs; and +- run as a non-root user after writable storage has been prepared. + +## Required configuration + +`NGIT_DOMAIN` is the only required application setting. It is the canonical +public hostname, without a URL scheme or path: + +```text +NGIT_DOMAIN=ngit.example.com +``` + +When the relay is mounted below a shared hostname, set `NGIT_BASE_PATH` to the +public path. Otherwise keep the default `/`. + +The listener defaults to `127.0.0.1:7334`. A container or managed platform must +override it to an externally reachable container address. The supplied image +does this automatically, using `0.0.0.0:${PORT}` when the platform provides +`PORT` and `0.0.0.0:7334` otherwise. + +See the [configuration reference](configuration.md) for optional policy, +sync, curation, resource-limit, and private-service settings. + +## Public endpoint + +The public endpoint must preserve HTTP methods, request bodies, query strings, +and WebSocket upgrades. TLS normally terminates at Caddy, nginx, an ingress, +or the hosting platform. + +Configure `NGIT_TRUSTED_PROXY_CIDRS` only when all of the following are true: + +- the backend is unreachable except through the named proxies; +- the proxy overwrites or safely appends forwarding headers; and +- every trusted hop is represented by an exact address or narrow CIDR. + +Leaving the setting empty is safe. The relay then ignores forwarding headers +and records the proxy address instead of the original client address. + +## Durable state + +Every production deployment must persist these three items together: + +| Path | Contents | +| --- | --- | +| `.relay-owner.nsec` | Generated relay identity, unless an external credential supplies it | +| `git/` | Git object families, repository views, holding data, and maintenance queues | +| `relay/` | LMDB relay events, lifecycle metadata, cursors, and rejected-event state | + +The container contract places them below `/data`: + +```text +/data/.relay-owner.nsec +/data/git/ +/data/relay/ +``` + +The NixOS and generic systemd deployments use the same relative layout below +their configured state directory. + +Back up the entire state directory from one point in time. A simple portable +backup should stop the service first; a storage-level snapshot may instead +quiesce or atomically snapshot the filesystem. A backup that omits the owner +key changes the relay identity on restore. A backup that captures Git and LMDB +at unrelated times can restore an inconsistent authorization view. + +## One writer + +An ngit-grasp state directory has exactly one writer. Do not: + +- run multiple replicas against one filesystem or network volume; +- attach independent local volumes to replicas and load-balance between them; +- use rolling or blue/green replacement that overlaps two writers; or +- scale the service to zero when that can discard or detach its durable volume. + +Run one instance and scale it vertically. Separate instances are supported +only when each has its own domain, relay identity, and state directory. + +## Identity and secrets + +The default is intentionally low ceremony: on first start, ngit-grasp creates +`.relay-owner.nsec` with mode `0600` in its working directory and reuses it on +later starts. Protect and back up that file. + +To supply an existing identity: + +- use the `relay_owner_nsec` systemd credential where available; +- mount `.relay-owner.nsec` into the persistent state directory; or +- as a less preferred container fallback, use the + `NGIT_RELAY_OWNER_NSEC` secret environment variable. + +Never put an nsec in a command-line argument, image, Compose file, Nix store, +checked-in environment file, or hosting template. + +## Health and verification + +The HTTP listener starts only after configuration validation, storage +initialization, migrations, and startup integrity work. Until a dedicated +health route exists, platform health checks should request the configured base +path and accept any `2xx` response. + +After deployment, run: + +```bash +scripts/verify-deployment.sh https://ngit.example.com +``` + +The verifier checks NIP-11, an HTTP/1.1 WebSocket upgrade, and Prometheus +metrics with bounded network deadlines. Set `VERIFY_METRICS=false` only when +metrics were deliberately disabled. + +## Upgrade and rollback + +Pin deployments to a reviewed tag or revision. Before changing versions: + +1. read `CHANGELOG.md` and any linked migration guide; +2. take a restorable snapshot of the complete state directory; +3. stop the old writer before starting the new one; +4. wait for startup migrations and integrity summaries; and +5. run the deployment verifier. + +A binary or image rollback is not necessarily a data rollback. When a release +changes storage, restore the matching pre-upgrade state snapshot before +starting the older version. The +[Git family storage upgrade guide](../how-to/upgrade-git-family-storage.md) +documents the current one-way migration boundary. diff --git a/scripts/test-container-deployment.sh b/scripts/test-container-deployment.sh new file mode 100755 index 0000000..8ec0bbb --- /dev/null +++ b/scripts/test-container-deployment.sh @@ -0,0 +1,95 @@ +#!/bin/sh +set -eu + +container_engine=${CONTAINER_ENGINE:-docker} +test_suffix=$$ +container_name=ngit-grasp-deployment-test-${test_suffix} +volume_name=ngit-grasp-deployment-test-${test_suffix} +if [ -n "${NGIT_TEST_IMAGE:-}" ]; then + image_name=${NGIT_TEST_IMAGE} + remove_test_image=false +else + image_name=ngit-grasp:deployment-test-${test_suffix} + remove_test_image=true +fi + +case "${container_name}" in + ngit-grasp-deployment-test-[0-9]*) ;; + *) + echo "refusing to use unexpected test resource name" >&2 + exit 2 + ;; +esac + +if ! command -v "${container_engine}" >/dev/null 2>&1; then + echo "missing container engine: ${container_engine}" >&2 + exit 2 +fi +for required_command in curl jq; do + if ! command -v "${required_command}" >/dev/null 2>&1; then + echo "missing required command: ${required_command}" >&2 + exit 2 + fi +done + +cleanup() { + "${container_engine}" rm --force "${container_name}" >/dev/null 2>&1 || true + "${container_engine}" volume rm "${volume_name}" >/dev/null 2>&1 || true + if [ "${remove_test_image}" = true ]; then + "${container_engine}" image rm "${image_name}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT HUP INT TERM + +start_container() { + "${container_engine}" run \ + --detach \ + --name "${container_name}" \ + --publish 127.0.0.1::7334 \ + --volume "${volume_name}:/data" \ + --env NGIT_DOMAIN=localhost \ + "${image_name}" >/dev/null +} + +relay_url() { + published_port=$("${container_engine}" port "${container_name}" 7334/tcp) + published_port=${published_port##*:} + printf 'http://127.0.0.1:%s' "${published_port}" +} + +relay_pubkey() { + curl \ + --fail \ + --silent \ + --show-error \ + --connect-timeout 5 \ + --max-time 15 \ + --retry 30 \ + --retry-all-errors \ + --retry-max-time 180 \ + --header 'Accept: application/nostr+json' \ + "$1" | jq -er '.pubkey' +} + +"${container_engine}" build --tag "${image_name}" . +"${container_engine}" volume create "${volume_name}" >/dev/null + +start_container +first_url=$(relay_url) +first_pubkey=$(relay_pubkey "${first_url}") +"${container_engine}" exec "${container_name}" \ + test -s /data/.relay-owner.nsec + +"${container_engine}" stop --time 300 "${container_name}" >/dev/null +"${container_engine}" rm "${container_name}" >/dev/null + +start_container +second_url=$(relay_url) +second_pubkey=$(relay_pubkey "${second_url}") + +if [ "${first_pubkey}" != "${second_pubkey}" ]; then + echo "relay identity changed after container replacement" >&2 + exit 1 +fi + +echo "container deployment verified; relay identity persisted" diff --git a/scripts/verify-deployment.sh b/scripts/verify-deployment.sh new file mode 100755 index 0000000..144ae36 --- /dev/null +++ b/scripts/verify-deployment.sh @@ -0,0 +1,99 @@ +#!/bin/sh +set -eu + +usage() { + echo "Usage: $0 " >&2 + echo "Example: $0 https://ngit.example.com" >&2 +} + +if [ "$#" -ne 1 ]; then + usage + exit 2 +fi + +base_url=${1%/} +case "${base_url}" in + http://*|https://*) ;; + *) + echo "public base URL must start with http:// or https://" >&2 + exit 2 + ;; +esac + +for required_command in curl grep mktemp; do + if ! command -v "${required_command}" >/dev/null 2>&1; then + echo "missing required command: ${required_command}" >&2 + exit 2 + fi +done + +temporary_directory=$(mktemp -d) +cleanup() { + rm -rf -- "${temporary_directory}" +} +trap cleanup EXIT HUP INT TERM + +nip11_body=${temporary_directory}/nip11.json +websocket_headers=${temporary_directory}/websocket.headers + +curl \ + --fail \ + --silent \ + --show-error \ + --connect-timeout 5 \ + --max-time 15 \ + --retry 20 \ + --retry-all-errors \ + --retry-max-time 120 \ + --header 'Accept: application/nostr+json' \ + --output "${nip11_body}" \ + "${base_url}" + +if command -v jq >/dev/null 2>&1; then + jq -e ' + (.pubkey | type == "string" and length == 64) and + (.supported_nips | type == "array") + ' "${nip11_body}" >/dev/null +else + grep -q '"pubkey"' "${nip11_body}" + grep -q '"supported_nips"' "${nip11_body}" +fi +echo "ok: NIP-11 relay information" + +set +e +curl \ + --http1.1 \ + --silent \ + --show-error \ + --connect-timeout 5 \ + --max-time 5 \ + --dump-header "${websocket_headers}" \ + --output /dev/null \ + --header 'Connection: Upgrade' \ + --header 'Upgrade: websocket' \ + --header 'Sec-WebSocket-Version: 13' \ + --header 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + "${base_url}" +websocket_curl_status=$? +set -e + +if ! grep -Eq '^HTTP/[0-9.]+ 101([[:space:]]|$)' "${websocket_headers}"; then + echo "WebSocket upgrade failed (curl status ${websocket_curl_status})" >&2 + cat "${websocket_headers}" >&2 + exit 1 +fi +echo "ok: WebSocket upgrade" + +if [ "${VERIFY_METRICS:-true}" = true ]; then + curl \ + --fail \ + --silent \ + --show-error \ + --connect-timeout 5 \ + --max-time 15 \ + --output /dev/null \ + "${base_url}/metrics" + echo "ok: Prometheus metrics" +fi + +echo "deployment verified: ${base_url}" From 28c881ba97b6b7d774a34fb4668429a93f6fdb4b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 20 Aug 2026 19:24:19 +0000 Subject: [PATCH 2/5] feat(deploy): add managed hosting templates Agents need repeatable provider inputs instead of translating a generic container guide into mutable dashboard settings on every deployment. Add Railway, Render, and Fly.io configurations that all build the canonical Dockerfile, mount /data, expose one HTTP service, allow bounded shutdown, and avoid overlapping writers. Pair them with provider-specific CLI and dashboard instructions, DNS verification, backup cautions, and one-instance constraints. The templates assume each provider terminates TLS and preserves WebSocket upgrades. Render still requires a supported connected Git source for Blueprint automation, and provider credentials or account mutations are deliberately outside this commit. Validated Railway and Render against their current published JSON schemas, parsed both TOML files and all YAML, checked Fly fields against its current official reference, verified local documentation links, and ran git diff --check. flyctl strict validation was not possible without a Fly access token. --- docs/how-to/deploy-paas.md | 162 +++++++++++++++++++++++++++++++++++++ fly.toml.example | 42 ++++++++++ railway.toml | 12 +++ render.yaml | 19 +++++ 4 files changed, 235 insertions(+) create mode 100644 docs/how-to/deploy-paas.md create mode 100644 fly.toml.example create mode 100644 railway.toml create mode 100644 render.yaml diff --git a/docs/how-to/deploy-paas.md b/docs/how-to/deploy-paas.md new file mode 100644 index 0000000..157568c --- /dev/null +++ b/docs/how-to/deploy-paas.md @@ -0,0 +1,162 @@ +# Deploy on managed hosting + +The supplied container can run on Railway, Render, and Fly.io when each service +has a custom domain, one persistent `/data` volume, and exactly one running +instance. These platforms terminate TLS and proxy WebSockets to ngit-grasp. + +Managed hosting is best for a small or moderate relay whose operator accepts +brief upgrade downtime. For a large existing relay, use NixOS, systemd, or +Compose so startup migrations and storage snapshots remain under direct +operator control. + +Read the [deployment contract](../reference/deployment-contract.md) first. In +particular, provider replicas do not make ngit-grasp highly available: their +local volumes do not replicate application state. + +## Common requirements + +For every provider: + +- set `NGIT_DOMAIN` to the final custom hostname, not the provider hostname; +- mount durable storage at `/data` before the first successful start; +- keep the instance count at one and disable scale-to-zero; +- keep `NGIT_BASE_PATH=/` unless path routing has been designed explicitly; +- allow at least five minutes between `SIGTERM` and forced termination; and +- take an external backup of `/data`, including `.relay-owner.nsec`. + +The image maps a platform-provided `PORT` to `0.0.0.0:${PORT}`. Do not set +`NGIT_BIND_ADDRESS` unless the provider template below does so explicitly. + +## Railway + +`railway.toml` selects the Dockerfile, uses `/` as the health check, disables +old/new deployment overlap, and gives shutdown five minutes. Railway storage is +configured separately from config-as-code. See Railway's +[config-as-code reference](https://docs.railway.com/config-as-code/reference) +and [volume guide](https://docs.railway.com/volumes) for the provider-side +details. + +From the repository root with the Railway CLI installed: + +```bash +railway login +railway init +railway add --service ngit-grasp +railway service ngit-grasp +railway variable set NGIT_DOMAIN=ngit.example.com NGIT_BASE_PATH=/ +railway volume add --mount-path /data +railway up +railway domain ngit.example.com +``` + +Add the DNS records returned by `railway domain`, then inspect status and verify +the public relay: + +```bash +railway domain status ngit.example.com +railway deployment list --json +scripts/verify-deployment.sh https://ngit.example.com +``` + +Do not add replicas or multi-region configuration. Railway mounts a new volume +as root; the image entrypoint prepares it and drops privileges before starting +ngit-grasp. + +Railway volume backups are useful recovery points, but also export or snapshot +state outside the platform. During a large storage migration, increase the +health-check timeout or move the upgrade to a directly operated host. + +## Render + +`render.yaml` describes a paid `starter` web service with a 10 GB disk mounted +at `/data`, a root health check, a five-minute shutdown delay, and automatic +deploys disabled. Render prompts for `NGIT_DOMAIN` when the Blueprint is +created. The fields follow Render's +[Blueprint specification](https://render.com/docs/blueprint-spec); its +[persistent disk guide](https://render.com/docs/disks) describes the storage +and scaling constraints. + +Render Blueprints require a repository connected through one of Render's +supported Git providers. Until ngit-grasp has an authorized mirror or published +OCI image, use Render's **Public Git Repository** flow with the canonical URL: + +```text +https://gitnostr.com/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git +``` + +Choose Docker and apply the values from `render.yaml` in the service form: + +- environment variable `NGIT_DOMAIN=ngit.example.com`; +- persistent disk mounted at `/data`; +- health check path `/`; +- maximum shutdown delay 300 seconds; and +- one instance with automatic deploys disabled. + +Add and verify the custom domain before publishing it in repository +announcements. Render services backed by a persistent disk cannot run multiple +instances and have brief downtime during deploys, which matches the relay's +single-writer requirement. + +After a manual deploy, run: + +```bash +scripts/verify-deployment.sh https://ngit.example.com +``` + +Use Render disk snapshots plus an independent backup. Only `/data` persists; +all other container filesystem changes are ephemeral. + +## Fly.io + +Fly volumes are local to one Machine and are not automatically replicated. The +template therefore disables autostop, uses an immediate replacement strategy, +and must be deployed with high-availability seeding disabled. See Fly's +[configuration reference](https://fly.io/docs/reference/configuration/) and +[volume overview](https://fly.io/docs/volumes/overview/) for the underlying +platform behavior. + +Copy and edit the template: + +```bash +cp fly.toml.example fly.toml +# Set a unique app name, primary region, and NGIT_DOMAIN in fly.toml. +fly config validate --strict --config fly.toml +``` + +Create the app and one volume in the same region, then deploy one Machine: + +```bash +fly apps create replace-with-a-unique-ngit-grasp-name +fly volumes create ngit_grasp_data --region ord --size 10 +fly deploy --ha=false +fly scale count 1 +fly certs add ngit.example.com +``` + +Use the app name and region selected in `fly.toml`, add the DNS records shown by +`fly certs`, then verify: + +```bash +fly checks list +fly scale show +scripts/verify-deployment.sh https://ngit.example.com +``` + +Never scale above one: Fly creates a separate empty volume for each additional +Machine and does not copy ngit-grasp state. A single Machine and volume can be +unavailable after host failure, so maintain an external backup rather than +relying only on Fly snapshots. + +## Upgrades + +Managed volume deployments cannot provide a safe zero-downtime writer handoff. +Expect brief downtime: + +1. disable automatic deploys; +2. take a complete provider snapshot and an external backup; +3. deploy the pinned revision with one instance; +4. inspect startup and integrity logs; and +5. run the public verifier. + +For a storage-changing release, restore both the prior executable and its +matching pre-upgrade `/data` snapshot when rolling back. diff --git a/fly.toml.example b/fly.toml.example new file mode 100644 index 0000000..26cb2d3 --- /dev/null +++ b/fly.toml.example @@ -0,0 +1,42 @@ +# Copy this file to fly.toml, then change the app, primary_region, and +# NGIT_DOMAIN values before creating the Fly app. +app = "replace-with-a-unique-ngit-grasp-name" +primary_region = "ord" +kill_signal = "SIGTERM" +kill_timeout = 300 + +[build] +dockerfile = "Dockerfile" + +[env] +NGIT_DOMAIN = "ngit.example.com" +NGIT_BASE_PATH = "/" +NGIT_BIND_ADDRESS = "0.0.0.0:7334" +NGIT_GIT_DATA_PATH = "/data/git" +NGIT_RELAY_DATA_PATH = "/data/relay" +NGIT_LOG_LEVEL = "info" + +[deploy] +strategy = "immediate" + +[http_service] +internal_port = 7334 +force_https = true +auto_stop_machines = "off" +auto_start_machines = true +min_machines_running = 1 + +[[http_service.checks]] +grace_period = "30s" +interval = "30s" +method = "GET" +timeout = "5s" +path = "/" + +[[mounts]] +source = "ngit_grasp_data" +destination = "/data" + +[[vm]] +size = "shared-cpu-1x" +memory = "1gb" diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..d668c25 --- /dev/null +++ b/railway.toml @@ -0,0 +1,12 @@ +"$schema" = "https://railway.com/railway.schema.json" + +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/" +healthcheckTimeout = 300 +restartPolicyType = "ALWAYS" +overlapSeconds = 0 +drainingSeconds = 300 diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..c5705fa --- /dev/null +++ b/render.yaml @@ -0,0 +1,19 @@ +services: + - type: web + name: ngit-grasp + runtime: docker + plan: starter + autoDeployTrigger: off + healthCheckPath: / + maxShutdownDelaySeconds: 300 + disk: + name: ngit-grasp-data + mountPath: /data + sizeGB: 10 + envVars: + - key: NGIT_DOMAIN + sync: false + - key: NGIT_BASE_PATH + value: / + - key: NGIT_LOG_LEVEL + value: info From 22bbd485374718d762cef4c3d9ef6792964f38b7 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 20 Aug 2026 19:26:20 +0000 Subject: [PATCH 3/5] docs(deploy): add host-specific production paths The production guide was NixOS-only despite presenting itself as the general deployment entry point, and its examples referenced an unavailable GitHub source and a hardening control the module does not set. Turn the entry point into an environment chooser, preserve the corrected NixOS material in its own guide, add a hardened generic systemd unit and repeatable Linux installation, document the preferred unprivileged Proxmox layout, and update repository navigation and architecture references. Each path assumes the shared deployment contract from the container change. Kubernetes automation, remote host mutation, and changes to the existing NixOS module are deliberately excluded. Validated the canonical Git remote with git ls-remote, parsed and scored the systemd unit with systemd-analyze, checked all new deployment-guide links, removed trailing whitespace, scanned the staged diff for key-shaped nsec values, and ran git diff --check. --- AGENTS.md | 2 +- README.md | 23 +- deploy/systemd/ngit-grasp.env.example | 15 + deploy/systemd/ngit-grasp.service | 38 ++ docs/README.md | 7 +- docs/explanation/architecture.md | 49 +-- docs/how-to/README.md | 24 +- docs/how-to/deploy-linux.md | 102 +++++ docs/how-to/deploy-nixos.md | 570 ++++++++++++++++++++++++++ docs/how-to/deploy-proxmox-lxc.md | 58 +++ docs/how-to/deploy.md | 560 ++----------------------- docs/reference/README.md | 7 + nix/example-configuration.nix | 3 +- 13 files changed, 866 insertions(+), 592 deletions(-) create mode 100644 deploy/systemd/ngit-grasp.env.example create mode 100644 deploy/systemd/ngit-grasp.service create mode 100644 docs/how-to/deploy-linux.md create mode 100644 docs/how-to/deploy-nixos.md create mode 100644 docs/how-to/deploy-proxmox-lxc.md diff --git a/AGENTS.md b/AGENTS.md index 6319ea5..159158f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ forces `ExecStart`, which coerces `src = ../.`; a standalone local path is not Git-filtered and may hash or copy ignored `target/` and worktree data. Use the Git-backed flake module, or a builder stub that ignores all build attributes and keeps `src` lazy. See -[`docs/how-to/deploy.md`](docs/how-to/deploy.md#resource-safe-module-validation) +[`docs/how-to/deploy-nixos.md`](docs/how-to/deploy-nixos.md#resource-safe-module-validation) for resource-safe validation and deployment. ### Testing ngit-grasp (Main Project) diff --git a/README.md b/README.md index d8af828..4020b20 100644 --- a/README.md +++ b/README.md @@ -406,9 +406,22 @@ This a useful feature of other git servers. ## Quick Start -Tagged releases provide a statically linked x86_64 Linux archive accompanied -by `SHA256SUMS`. The archive can be installed without a Rust or Nix toolchain; -on a Linux flake system, build the same output with `nix build .#static`. +For production, start with the [deployment chooser](docs/how-to/deploy.md). +The repository ships Docker and Compose configurations, a NixOS module, a +hardened systemd unit, and templates for selected managed hosts. All supported +paths preserve the same +[deployment contract](docs/reference/deployment-contract.md). + +The shortest fresh-VPS path uses Docker Compose and Caddy: + +```bash +cp deploy.env.example .env +# Set NGIT_DOMAIN in .env and point DNS at this server. +docker compose -f compose.yaml -f compose.caddy.yaml up --build -d +scripts/verify-deployment.sh https://ngit.example.com +``` + +For development from source: ```bash # install ngit @@ -443,7 +456,9 @@ nix develop -c cargo test --lib - Purgatory system activates, ready to hunt for missing git data - Prometheus metrics exposed at `/metrics` -**Don't have Nix?** See [Getting Started Tutorial](docs/tutorials/getting-started.md) for alternative setup methods. +**Don't have Nix?** Use the container path above or see the +[Getting Started Tutorial](docs/tutorials/getting-started.md) for development +alternatives. ## Configuration diff --git a/deploy/systemd/ngit-grasp.env.example b/deploy/systemd/ngit-grasp.env.example new file mode 100644 index 0000000..03262ac --- /dev/null +++ b/deploy/systemd/ngit-grasp.env.example @@ -0,0 +1,15 @@ +# Required canonical public hostname, without a scheme or path. +NGIT_DOMAIN=ngit.example.com + +NGIT_BASE_PATH=/ +NGIT_BIND_ADDRESS=127.0.0.1:7334 +NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git +NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay +NGIT_DATABASE_BACKEND=lmdb +NGIT_LOG_LEVEL=info + +# Optional initial relay. Sync discovers additional relays automatically. +# NGIT_SYNC_BOOTSTRAP_RELAY_URL=wss://relay.ngit.dev + +# Prefer /var/lib/ngit-grasp/.relay-owner.nsec or a systemd credential over an +# environment secret. Never put NGIT_RELAY_OWNER_NSEC in this example file. diff --git a/deploy/systemd/ngit-grasp.service b/deploy/systemd/ngit-grasp.service new file mode 100644 index 0000000..f0069cb --- /dev/null +++ b/deploy/systemd/ngit-grasp.service @@ -0,0 +1,38 @@ +[Unit] +Description=ngit-grasp GRASP relay +Documentation=https://gitworkshop.dev/danconwaydev.com/ngit-grasp +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=ngit-grasp +Group=ngit-grasp +WorkingDirectory=/var/lib/ngit-grasp +EnvironmentFile=/etc/ngit-grasp/ngit-grasp.env +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/local/bin/ngit-grasp +Restart=on-failure +RestartSec=10s +TimeoutStopSec=300s +UMask=0077 + +NoNewPrivileges=true +PrivateDevices=true +PrivateTmp=true +ProtectControlGroups=true +ProtectHome=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectSystem=strict +ReadWritePaths=/var/lib/ngit-grasp +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/README.md b/docs/README.md index af2e51a..daa0330 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,7 +36,8 @@ WORKING │ How-To │ Reference │ **For:** Users with basic knowledge solving real problems **Style:** Practical recipes and solutions -- **[Deploy ngit-grasp](how-to/deploy.md)** - Production deployment guide +- **[Deploy ngit-grasp](how-to/deploy.md)** - Choose Docker, NixOS, Linux, Proxmox, or managed hosting +- **[Deployment contract](reference/deployment-contract.md)** - Shared runtime and persistence requirements - **[Configure Nix Flakes](how-to/nix-flakes.md)** - Nix development environment - **[Run Compliance Tests](how-to/test-compliance.md)** - GRASP compliance testing - **[Upgrade nostr-sdk](how-to/upgrade-nostr-sdk.md)** - Handling SDK upgrades @@ -74,8 +75,8 @@ WORKING │ How-To │ Reference │ ### I want to deploy ngit-grasp 1. Review [Configuration Reference](reference/configuration.md) -2. Follow [Deployment How-To](how-to/deploy.md) -3. Set up monitoring and backups +2. Choose an environment in [Deploy ngit-grasp](how-to/deploy.md) +3. Verify the deployment and test its backup ### I want to develop on ngit-grasp 1. Follow [Getting Started Tutorial](tutorials/getting-started.md) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index e227f79..c08ee9f 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -868,47 +868,20 @@ Relax the write policy to accept all repository announcements regardless of clon ## Deployment -### Single Binary +The runtime remains one binary plus Git, but persistence, identity, proxying, +and single-writer behavior are part of the production boundary. The normative +[deployment contract](../reference/deployment-contract.md) owns those shared +requirements. -```bash -cargo build --release -NGIT_RELAY_OWNER_NSEC=nsec1... \ - ./target/release/ngit-grasp --domain example.com -``` +Supported artifacts are maintained alongside their operating guides: -### Docker +- the root `Dockerfile` and Compose configurations; +- `deploy/systemd/ngit-grasp.service` for conventional Linux; +- `nix/module.nix` for declarative NixOS instances; and +- managed-host templates for single-instance deployments. -```dockerfile -FROM rust:1.75 as builder -WORKDIR /app -COPY . . -RUN cargo build --release - -FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/ngit-grasp /usr/local/bin/ -EXPOSE 7334 -CMD ["ngit-grasp"] -``` - -### Systemd - -```ini -[Unit] -Description=ngit-grasp GRASP server -After=network.target - -[Service] -Type=simple -User=git -WorkingDirectory=/opt/ngit-grasp -EnvironmentFile=/opt/ngit-grasp/.env -ExecStart=/usr/local/bin/ngit-grasp -Restart=on-failure - -[Install] -WantedBy=multi-user.target -``` +See the [deployment chooser](../how-to/deploy.md) rather than copying an +illustrative container or service definition from this architecture document. ## Security Considerations diff --git a/docs/how-to/README.md b/docs/how-to/README.md index ef59732..ca15a9d 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -24,6 +24,18 @@ How-to guides are **recipes** that show you how to solve specific problems or ac ## Available How-To Guides +### [Deploy ngit-grasp](deploy.md) + +**Problem:** Run a durable production relay in a supported hosting environment + +**You'll learn:** + +- Choose Docker, NixOS, systemd Linux, Proxmox, or managed hosting +- Preserve the relay identity, Git repositories, and LMDB state +- Configure TLS, verify protocols, back up, and upgrade safely + +--- + ### [Upgrade from v2 to v3 Git family storage](upgrade-git-family-storage.md) **Problem:** Perform the one-way identifier-family storage migration safely @@ -64,18 +76,6 @@ How-to guides are **recipes** that show you how to solve specific problems or ac ## Planned How-To Guides -### Deploy ngit-grasp -**Status:** 🔜 Planned (waiting for main server) - -**Problem:** Deploy to production -**You'll learn:** -- Server requirements -- Reverse proxy setup (nginx/Caddy) -- SSL/TLS configuration -- Monitoring and logging - ---- - ### Run Compliance Tests **Status:** 🔜 Planned diff --git a/docs/how-to/deploy-linux.md b/docs/how-to/deploy-linux.md new file mode 100644 index 0000000..193205a --- /dev/null +++ b/docs/how-to/deploy-linux.md @@ -0,0 +1,102 @@ +# Deploy a static binary with systemd + +Use this path for a conventional Debian, Ubuntu, Fedora, or other systemd Linux +server when containers are unnecessary. Build the portable binary on a Nix +build machine, then copy only the binary and service files to the server. + +## Build a pinned binary + +From a clean checkout of the reviewed tag or revision: + +```bash +nix build .#static +file result/bin/ngit-grasp +``` + +Copy `result/bin/ngit-grasp`, `deploy/systemd/ngit-grasp.service`, and +`deploy/systemd/ngit-grasp.env.example` to the server through your normal +authenticated deployment channel. + +The deployment host does not need Nix or Rust. It does need Git and trusted CA +certificates: + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates git +``` + +Use the equivalent packages on non-Debian distributions. + +## Install + +```bash +getent group ngit-grasp >/dev/null || sudo groupadd --system ngit-grasp +id -u ngit-grasp >/dev/null 2>&1 || \ + sudo useradd --system --gid ngit-grasp --home-dir /var/lib/ngit-grasp \ + --create-home --shell /usr/sbin/nologin ngit-grasp +sudo install -Dm755 ngit-grasp /usr/local/bin/ngit-grasp +sudo install -Dm644 ngit-grasp.service \ + /etc/systemd/system/ngit-grasp.service +sudo install -Dm640 -o root -g ngit-grasp ngit-grasp.env.example \ + /etc/ngit-grasp/ngit-grasp.env +sudo install -d -m 0750 -o ngit-grasp -g ngit-grasp \ + /var/lib/ngit-grasp \ + /var/lib/ngit-grasp/git \ + /var/lib/ngit-grasp/relay +``` + +Edit `/etc/ngit-grasp/ngit-grasp.env` and set `NGIT_DOMAIN`. + +If restoring an existing identity, install `.relay-owner.nsec` as mode `0600` +owned by `ngit-grasp` under `/var/lib/ngit-grasp`. Otherwise the first start +generates it there. + +## Reverse proxy and TLS + +Keep the service bound to `127.0.0.1:7334`. For Caddy, a domain-root virtual +host is: + +```caddyfile +ngit.example.com { + reverse_proxy 127.0.0.1:7334 +} +``` + +Point DNS at the server and replace `ngit.example.com` in both Caddy and the +ngit-grasp environment file. Caddy preserves WebSocket upgrades automatically. + +## Start and verify + +On the server: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now ngit-grasp +sudo systemctl status ngit-grasp --no-pager +sudo journalctl -u ngit-grasp -n 50 --no-pager +``` + +From the repository checkout on the operator workstation: + +```bash +scripts/verify-deployment.sh https://ngit.example.com +``` + +The service unit applies a restrictive umask, filesystem protection, private +temporary directory, empty capability set, and bounded five-minute shutdown. + +## Upgrade and rollback + +Build the new pinned binary before touching the server. Read `CHANGELOG.md`, +stop the service, snapshot `/var/lib/ngit-grasp`, install the new binary, and +start the service: + +```bash +sudo systemctl stop ngit-grasp +sudo install -Dm755 ngit-grasp /usr/local/bin/ngit-grasp +sudo systemctl start ngit-grasp +``` + +Run the verifier and inspect startup integrity summaries. If the release +changed storage, restoring the old binary also requires restoring its matching +state snapshot. diff --git a/docs/how-to/deploy-nixos.md b/docs/how-to/deploy-nixos.md new file mode 100644 index 0000000..4a14d32 --- /dev/null +++ b/docs/how-to/deploy-nixos.md @@ -0,0 +1,570 @@ +# Deploy ngit-grasp on NixOS + +**Purpose:** Deploy ngit-grasp to a production NixOS server +**Difficulty:** Intermediate +**Time:** 30-60 minutes + +This guide implements the shared +[deployment contract](../reference/deployment-contract.md) with the repository's +NixOS module. For another environment, return to the +[deployment chooser](deploy.md). + +--- + +## Problem + +You want to: +- Deploy ngit-grasp to a NixOS server +- Configure it as a systemd service +- Set up reverse proxy (Caddy) +- Ensure proper security and monitoring + +--- + +## Prerequisites + +- NixOS server with SSH access +- Flakes enabled on server and local machine +- Domain name configured (DNS pointing to server) +- Basic knowledge of NixOS configuration + +--- + +## Solution + +### Step 1: Add ngit-grasp to Your Server's Flake + +In your server's `flake.nix`, add ngit-grasp as an input: + +```nix +{ + inputs = { + # Keep the nixpkgs input already used by this server configuration. + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + ngit-grasp.url = + "git+https://gitnostr.com/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git"; + }; + + outputs = { self, nixpkgs, ngit-grasp, ... }@inputs: { + nixosConfigurations.your-hostname = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + specialArgs = { inherit inputs; }; + modules = [ + ./configuration.nix + # ... other modules + ]; + }; + }; +} +``` + +--- + +### Step 2: Create Service Configuration + +Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`): + +```nix +{ inputs, ... }: + +{ + imports = [ inputs.ngit-grasp.nixosModules.default ]; + + services.ngit-grasp.production = { + enable = true; + domain = "ngit.example.com"; + + # Network + bindAddress = "127.0.0.1"; + port = 8082; + # Only Caddy can reach the loopback backend, so its forwarded client IP is trusted. + trustedProxyCidrs = [ "127.0.0.1/32" ]; + + # Storage + dataDir = "/persistent/ngit-grasp"; + + # Identity + relayName = "My GRASP Relay"; + relayDescription = "A Rust GRASP implementation with proactive sync"; + relayOwnerNsecFile = "/run/agenix/ngit-grasp-relay-owner-nsec"; + + # Sync - bootstrap from relay.ngit.dev + syncBootstrapRelayUrl = "wss://relay.ngit.dev"; + + # Metrics + metricsEnabled = true; + + # Logging + logLevel = "info"; + }; + + # Caddy reverse proxy + services.caddy.virtualHosts."ngit.example.com" = { + extraConfig = '' + reverse_proxy 127.0.0.1:8082 { + # Caddy manages X-Forwarded-For automatically. + header_up X-Real-IP {remote_host} + } + ''; + }; +} +``` + +**Key configuration options:** + +- **Instance name** (`production`): Can be any name. Used for systemd service (`ngit-grasp-production`) +- **domain**: Your relay's domain (used in GRASP validation) +- **port**: Local port (use reverse proxy for HTTPS) +- **trustedProxyCidrs**: Proxy source ranges allowed to supply the client IP + - Keep empty for a directly exposed listener + - Keep the backend private; trusting a public-facing source range permits spoofed headers + - Caddy automatically maintains `X-Forwarded-For`; `header_up`, not `header_down`, + changes headers sent to the backend +- **dataDir**: Where git repos and database are stored +- **relayOwnerNsecFile**: Path to file containing relay owner's nsec + - Passed to ngit-grasp as a protected systemd credential, not a process argument + - The runtime secret file must already exist (for example through agenix or sops-nix) + - Permissions on that external source file remain the operator or secret manager's responsibility + - Alternative: `relayOwnerNsec = "nsec1..."` (less secure, in nix store) + - If neither option is set, ngit-grasp loads or creates `.relay-owner.nsec` in `dataDir` +- **syncBootstrapRelayUrl**: Bootstrap relay to sync from on startup + +See [nix/example-configuration.nix](../../nix/example-configuration.nix) for more examples. + +--- + +### Step 3: Import the Service + +Import your service configuration in your main configuration file: + +```nix +# In configuration.nix or services/default.nix +{ + imports = [ + ./services/ngit-grasp.nix + # ... other services + ]; +} +``` + +--- + +### Step 4: Update Flake Lock + +```bash +cd /path/to/server/config +nix flake update ngit-grasp +git add flake.lock +git commit -m "Add ngit-grasp and update flake.lock" +``` + +--- + +### Step 5: Validate Configuration + +Before deploying, validate that the flake evaluates without starting its builds: + +```bash +nix flake check --no-build +``` + +#### Resource-safe module validation + +Nix copies path-valued build inputs into the store when they are forced. A Git +flake is materialized from its tracked files first, but a standalone path into a +working tree does not inherit that Git filtering. + +This matters when testing ngit-grasp's NixOS module locally. Importing +`nix/module.nix` is lazy by itself, but rendering an enabled service forces the +module-built package through `ExecStart`. The package's `src = ../.` then +resolves relative to that module. If the module was imported directly from a +working tree, Nix may recursively hash or copy ignored `target/`, `.git`, and +linked-worktree data while it appears to be evaluating the configuration. + +Use `inputs.ngit-grasp.nixosModules.default` from a Git-backed flake input, as +shown above. For local module changes, commit them to a temporary Git branch and +use that Git source, or replace `buildRustPackage` with a test stub that ignores +all build attributes so `src` remains unforced. Do not use a direct +working-tree module import for a test that enables an instance. + +Inspect the derivation plan before starting a build: + +```bash +nixos-rebuild dry-build --flake .#your-hostname +``` + +Multiple ngit-grasp instances should normally share one ngit-grasp package +derivation. Avoid service-level `ExecStart` overrides that force another flake +package or Rust toolchain. If distinct versions are intentional, build them +sequentially or on appropriately sized remote builders. For an initial local +build, constrain Nix while confirming the plan behaves as expected: + +```bash +nixos-rebuild build --flake .#your-hostname --max-jobs 1 --cores 2 +``` + +--- + +### Step 6: Deploy to Server + +Deploy the new configuration to your server: + +```bash +# Build and switch in one command (builds on server) +nixos-rebuild switch --flake .#your-hostname \ + --target-host user@server.example.com \ + --use-remote-sudo \ + --build-host user@server.example.com +``` + +**Alternative:** Build locally, then deploy: + +```bash +# Build locally +nixos-rebuild build --flake .#your-hostname + +# Deploy to server +nixos-rebuild switch --flake .#your-hostname \ + --target-host user@server.example.com \ + --use-remote-sudo +``` + +**Note:** Building locally requires your machine to trust the server's nix signing key. + +--- + +### Step 7: Verify Deployment + +SSH to the server and check the service: + +```bash +ssh user@server.example.com + +# Check service status +systemctl status ngit-grasp-production + +# View recent logs +journalctl -u ngit-grasp-production -n 50 --no-pager + +# Check if listening on port +ss -tlnp | grep 8082 +``` + +--- + +### Step 8: Test Functionality + +From your local machine, test the relay: + +```bash +# Test NIP-11 relay info +curl https://ngit.example.com -H "Accept: application/nostr+json" | jq + +# Test WebSocket connection +websocat wss://ngit.example.com +# Then type: ["REQ","test",{}] +# Should receive events + +# Test git clone (if you have repos) +git ls-remote https://ngit.example.com//.git +``` + +--- + +## Configuration Options + +### Required +- `enable` - Enable this instance +- `domain` - Domain where relay is hosted + +### Network +- `basePath` - Public URL mount path (default: `/`) +- `bindAddress` - IP to bind to (default: "127.0.0.1") +- `port` - Port to listen on (default: 7334) +- `trustedProxyCidrs` - Proxy networks allowed to provide the WebSocket client + IP (default: empty; forwarded headers ignored) + +### Storage +- `dataDir` - Base directory for data (default: /var/lib/ngit-grasp-{name}) +- `databaseBackend` - "lmdb" | "memory" (default: "lmdb") + +See [Upgrade Git family storage](upgrade-git-family-storage.md) before updating +an existing instance to a release that enables identifier-family storage. + +### Identity +- `relayName` - Relay name for NIP-11 (default: "{domain} grasp relay") +- `relayDescription` - Relay description +- `relayOwnerNsecFile` - Runtime secret file loaded as a systemd credential (recommended) +- `relayOwnerNsec` - Inline nsec (less secure) + +### Sync +- `syncBootstrapRelayUrl` - Bootstrap relay URL (optional) +- `syncDisableNegentropy` - Disable NIP-77 negentropy (default: false) +- `syncMaxBackoffSecs` - Max backoff for reconnection (default: 3600) +- `syncDisconnectCheckIntervalSecs` - Check interval (default: 60) +- `syncBaseBackoffSecs` - Base backoff time (default: 5) + +### Metrics +- `metricsEnabled` - Enable `/metrics` below the configured base path (default: true) +- `metricsConnectionPerIpAbuseThreshold` - Abuse threshold (default: 10) +- `metricsTopNRepos` - Number of top repos to track (default: 10) + +### Logging +- `logLevel` - "trace" | "debug" | "info" | "warn" | "error" (default: "info") + +### Security +- `user` - User to run as (default: "ngit-grasp-{name}") +- `group` - Group to run as (default: "ngit-grasp") + +See [nix/module.nix](../../nix/module.nix) for complete option definitions. + +--- + +## Systemd Service + +The NixOS module creates a systemd service: `ngit-grasp-{instance-name}` + +```bash +# Start/stop/restart +systemctl start ngit-grasp-production +systemctl stop ngit-grasp-production +systemctl restart ngit-grasp-production + +# Enable/disable autostart +systemctl enable ngit-grasp-production +systemctl disable ngit-grasp-production + +# View logs +journalctl -u ngit-grasp-production -f +journalctl -u ngit-grasp-production --since "1 hour ago" + +# Check status +systemctl status ngit-grasp-production +``` + +--- + +## Multiple Instances + +You can run multiple instances on the same server: + +```nix +services.ngit-grasp = { + production = { + enable = true; + domain = "ngit.example.com"; + port = 8082; + dataDir = "/persistent/ngit-production"; + }; + + staging = { + enable = true; + domain = "ngit-staging.example.com"; + port = 8083; + dataDir = "/persistent/ngit-staging"; + logLevel = "debug"; + }; +}; +``` + +Each instance: +- Runs as separate systemd service: `ngit-grasp-production`, `ngit-grasp-staging` +- Has its own user: `ngit-grasp-production`, `ngit-grasp-staging` +- Stores data in separate directory +- Can have different configuration + +--- + +## Troubleshooting + +### Service won't start + +**Check logs:** +```bash +journalctl -u ngit-grasp-production -n 50 +``` + +**Common issues:** +- Port already in use: Check with `ss -tlnp | grep 8082` +- Data directory permissions: Should be owned by service user +- Invalid nsec file: Check file exists and contains valid nsec + +### Can't connect via WebSocket + +**Check:** +- Service is running: `systemctl status ngit-grasp-production` +- Firewall allows connections: `nix run nixpkgs#nmap -- -p 443 ngit.example.com` +- Caddy is configured correctly: `systemctl status caddy` +- DNS resolves: `dig ngit.example.com` + +### Sync not working + +**Check logs for sync errors:** +```bash +journalctl -u ngit-grasp-production | grep -i sync +``` + +**Common issues:** +- Bootstrap relay URL incorrect or unreachable +- Network connectivity issues +- Bootstrap relay doesn't support negentropy (disable with `syncDisableNegentropy = true`) + +### High memory/CPU usage + +**Monitor metrics:** +```bash +curl http://localhost:8082/metrics +``` + +**Tune configuration:** +- Reduce `metricsTopNRepos` +- Increase `syncMaxBackoffSecs` +- Tune `syncMaxBackoffSecs` for your network conditions + +--- + +## Rollback + +If deployment fails, rollback to previous configuration: + +```bash +# On the server +nixos-rebuild switch --rollback + +# Or remotely +nixos-rebuild switch --rollback \ + --target-host user@server.example.com \ + --use-remote-sudo +``` + +If the release changed on-disk storage, a NixOS generation rollback is not +enough. Restore the matching pre-upgrade snapshot of the complete `dataDir` +before starting the older service. See the +[deployment contract](../reference/deployment-contract.md#upgrade-and-rollback). + +--- + +## Upgrading + +To upgrade ngit-grasp: + +```bash +# Update flake input +nix flake update ngit-grasp + +# Review changes +git diff flake.lock + +# Commit +git add flake.lock +git commit -m "Update ngit-grasp" + +# Deploy +nixos-rebuild switch --flake .#your-hostname \ + --target-host user@server.example.com \ + --use-remote-sudo \ + --build-host user@server.example.com +``` + +--- + +## Security Hardening + +The NixOS module includes systemd hardening: + +- `NoNewPrivileges = true` - Prevents privilege escalation +- `ProtectSystem = "strict"` - Read-only filesystem except dataDir +- `ProtectHome = true` - No access to home directories +- `PrivateTmp = true` - Private /tmp +- `RestrictAddressFamilies` - Only allow needed network families + +Additional recommendations: + +1. **Use a runtime secret file instead of an inline key:** + ```nix + relayOwnerNsecFile = "/run/agenix/ngit-grasp-relay-owner-nsec"; + # NOT: relayOwnerNsec = "nsec1..."; # Ends up in nix store! + ``` + + The module exposes the file to ngit-grasp as the `relay_owner_nsec` + systemd credential. The key does not appear in `ExecStart` or the process + command line. ngit-grasp does not modify the external source file; keep its + ownership and permissions restricted through your secret manager. + +2. **Restrict data directory permissions:** + ```bash + chmod 750 /persistent/ngit-grasp + chown ngit-grasp-production:ngit-grasp /persistent/ngit-grasp + ``` + +3. **Use HTTPS (reverse proxy required):** + - ngit-grasp binds to localhost by default + - Use Caddy/nginx for TLS termination + - Caddy handles certificates automatically + +4. **Monitor logs regularly:** + ```bash + journalctl -u ngit-grasp-production --since today | grep -i error + ``` + +--- + +## Monitoring + +### Prometheus Metrics + +ngit-grasp exposes Prometheus metrics at `/metrics`: + +```bash +curl http://localhost:8082/metrics +``` + +See [Prometheus Setup](./prometheus-setup.md) for complete monitoring guide. + +### Basic Health Checks + +```bash +# Check if service is running +systemctl is-active ngit-grasp-production + +# Check if port is listening +nc -zv localhost 8082 + +# Check relay info +curl https://ngit.example.com -H "Accept: application/nostr+json" + +# Check disk usage +du -sh /persistent/ngit-grasp/* +``` + +## Backup + +Back up the complete `dataDir`, including `.relay-owner.nsec`, `git/`, and +`relay/`, from one point in time. For a portable consistent backup, stop the +instance before taking the snapshot: + +```bash +systemctl stop ngit-grasp-production +# Snapshot or back up /persistent/ngit-grasp with the host's storage tooling. +systemctl start ngit-grasp-production +``` + +Keep an off-host copy and test restoration into an isolated, non-public +instance. Never start the restored copy alongside production with the same +domain and relay identity. + +--- + +## Related Documentation + +- [Configuration Reference](../reference/configuration.md) - All configuration options +- [NixOS Module](../../nix/module.nix) - Module source code +- [Example Configuration](../../nix/example-configuration.nix) - More examples +- [Prometheus Setup](./prometheus-setup.md) - Monitoring guide +- [Nix Flakes How-To](./nix-flakes.md) - Nix development environment +- [Deployment Contract](../reference/deployment-contract.md) - Shared runtime and persistence rules +- [Deployment Chooser](deploy.md) - Other supported environments + +--- + +*Part of the [ngit-grasp how-to guides](./)* diff --git a/docs/how-to/deploy-proxmox-lxc.md b/docs/how-to/deploy-proxmox-lxc.md new file mode 100644 index 0000000..b50a6f3 --- /dev/null +++ b/docs/how-to/deploy-proxmox-lxc.md @@ -0,0 +1,58 @@ +# Deploy in a Proxmox LXC container or VM + +ngit-grasp has no kernel-virtualization or nested-container requirement. The +preferred Proxmox layout is an unprivileged Debian or Ubuntu LXC running the +static binary as a systemd service. + +## Container requirements + +- an unprivileged LXC or ordinary VM with systemd +- network access to public HTTPS and WebSocket relays +- inbound HTTP/HTTPS through the Proxmox network or an external proxy +- durable storage sized for Git repositories, LMDB, holding data, and backups + +Docker nesting is not required for the direct binary path. Leave it disabled +unless using the Compose alternative below. + +## Direct systemd path + +Follow [Deploy a static binary with systemd](deploy-linux.md) inside the guest. +Keep `/var/lib/ngit-grasp` on storage included in the guest's snapshot and +backup policy. + +When the Proxmox host bind-mounts a dataset into an unprivileged LXC, map its +ownership to the container's `ngit-grasp` UID/GID before starting the service. +Verify this from inside the container: + +```bash +sudo -u ngit-grasp test -w /var/lib/ngit-grasp +sudo -u ngit-grasp git --version +``` + +Terminate TLS either inside the guest with Caddy or at an upstream proxy. If +the upstream proxy connects directly to ngit-grasp, add only that private +source address to `NGIT_TRUSTED_PROXY_CIDRS` and prevent other clients from +reaching port 7334. + +## Compose alternative + +If the guest already operates Docker or Podman, follow the +[Docker guide](deploy-docker.md). Docker inside LXC generally requires the +Proxmox nesting feature; the static binary path avoids that extra layer. + +Do not mount the host Docker socket into the relay container. ngit-grasp needs +Git, not a container daemon. + +## Backup and upgrade + +For a simple consistent backup: + +1. stop `ngit-grasp` inside the guest; +2. snapshot or back up the guest and its attached state storage; +3. start the service; and +4. verify the public endpoint. + +Proxmox snapshots are not a substitute for an off-host backup. Before a +storage-changing upgrade, confirm that the state volume participates in the +snapshot and that the snapshot can be restored without starting a second +writer against the production domain. diff --git a/docs/how-to/deploy.md b/docs/how-to/deploy.md index d0749fe..ed864c6 100644 --- a/docs/how-to/deploy.md +++ b/docs/how-to/deploy.md @@ -1,543 +1,37 @@ -# How-To: Deploy ngit-grasp to Production +# Deploy ngit-grasp -**Purpose:** Deploy ngit-grasp to a production NixOS server -**Difficulty:** Intermediate -**Time:** 30-60 minutes +ngit-grasp supports several single-instance production layouts. Choose the +guide matching the host you already operate; every guide implements the same +[deployment contract](../reference/deployment-contract.md). ---- +| Environment | Start here | Supplied artifact | +| --- | --- | --- | +| Docker or Podman host | [Docker and Compose](deploy-docker.md) | `Dockerfile`, `compose.yaml`, optional Caddy overlay | +| NixOS | [NixOS module](deploy-nixos.md) | `nixosModules.default` | +| Debian, Ubuntu, or another systemd Linux | [Static binary and systemd](deploy-linux.md) | Static flake package and service unit | +| Proxmox LXC or VM | [Proxmox](deploy-proxmox-lxc.md) | Direct systemd or Compose path | +| Railway, Render, or Fly.io | [Managed hosting](deploy-paas.md) | Provider configuration templates | -## Problem - -You want to: -- Deploy ngit-grasp to a NixOS server -- Configure it as a systemd service -- Set up reverse proxy (Caddy) -- Ensure proper security and monitoring - ---- - -## Prerequisites - -- NixOS server with SSH access -- Flakes enabled on server and local machine -- Domain name configured (DNS pointing to server) -- Basic knowledge of NixOS configuration - ---- - -## Solution - -### Step 1: Add ngit-grasp to Your Server's Flake - -In your server's `flake.nix`, add ngit-grasp as an input: - -```nix -{ - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; - ngit-grasp.url = "github:DanConwayDev/ngit-grasp"; - # or use a specific git repository: - # ngit-grasp.url = "git+https://git.shakespeare.diy/npub.../ngit-grasp.git"; - }; - - outputs = { self, nixpkgs, ngit-grasp, ... }@inputs: { - nixosConfigurations.your-hostname = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - specialArgs = { inherit inputs; }; - modules = [ - ./configuration.nix - # ... other modules - ]; - }; - }; -} -``` - ---- - -### Step 2: Create Service Configuration - -Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`): - -```nix -{ inputs, ... }: - -{ - imports = [ inputs.ngit-grasp.nixosModules.default ]; - - services.ngit-grasp.production = { - enable = true; - domain = "ngit.example.com"; - - # Network - bindAddress = "127.0.0.1"; - port = 8082; - # Only Caddy can reach the loopback backend, so its forwarded client IP is trusted. - trustedProxyCidrs = [ "127.0.0.1/32" ]; - - # Storage - dataDir = "/persistent/ngit-grasp"; - - # Identity - relayName = "My GRASP Relay"; - relayDescription = "A Rust GRASP implementation with proactive sync"; - relayOwnerNsecFile = "/run/agenix/ngit-grasp-relay-owner-nsec"; - - # Sync - bootstrap from relay.ngit.dev - syncBootstrapRelayUrl = "wss://relay.ngit.dev"; - - # Metrics - metricsEnabled = true; - - # Logging - logLevel = "info"; - }; - - # Caddy reverse proxy - services.caddy.virtualHosts."ngit.example.com" = { - extraConfig = '' - reverse_proxy 127.0.0.1:8082 { - # Caddy manages X-Forwarded-For automatically. - header_up X-Real-IP {remote_host} - } - ''; - }; -} -``` - -**Key configuration options:** - -- **Instance name** (`production`): Can be any name. Used for systemd service (`ngit-grasp-production`) -- **domain**: Your relay's domain (used in GRASP validation) -- **port**: Local port (use reverse proxy for HTTPS) -- **trustedProxyCidrs**: Proxy source ranges allowed to supply the client IP - - Keep empty for a directly exposed listener - - Keep the backend private; trusting a public-facing source range permits spoofed headers - - Caddy automatically maintains `X-Forwarded-For`; `header_up`, not `header_down`, - changes headers sent to the backend -- **dataDir**: Where git repos and database are stored -- **relayOwnerNsecFile**: Path to file containing relay owner's nsec - - Passed to ngit-grasp as a protected systemd credential, not a process argument - - The runtime secret file must already exist (for example through agenix or sops-nix) - - Permissions on that external source file remain the operator or secret manager's responsibility - - Alternative: `relayOwnerNsec = "nsec1..."` (less secure, in nix store) - - If neither option is set, ngit-grasp loads or creates `.relay-owner.nsec` in `dataDir` -- **syncBootstrapRelayUrl**: Bootstrap relay to sync from on startup - -See [nix/example-configuration.nix](../../nix/example-configuration.nix) for more examples. - ---- - -### Step 3: Import the Service - -Import your service configuration in your main configuration file: - -```nix -# In configuration.nix or services/default.nix -{ - imports = [ - ./services/ngit-grasp.nix - # ... other services - ]; -} -``` - ---- - -### Step 4: Update Flake Lock +For a fresh internet-facing VPS, the shortest supported path is Docker Compose +with the Caddy overlay: ```bash -cd /path/to/server/config -nix flake update ngit-grasp -git add flake.lock -git commit -m "Add ngit-grasp and update flake.lock" +cp deploy.env.example .env +# Set NGIT_DOMAIN in .env and point its DNS records at this server. +docker compose -f compose.yaml -f compose.caddy.yaml up --build -d +scripts/verify-deployment.sh https://ngit.example.com ``` ---- +The Caddy path requires ports 80 and 443. If the host already has a reverse +proxy, follow the loopback-only path in the Docker guide instead. -### Step 5: Validate Configuration +## Unsupported layouts -Before deploying, validate that the flake evaluates without starting its builds: +Do not deploy ngit-grasp to serverless functions, an ephemeral filesystem, or +multiple replicas. It owns long-lived WebSockets, background synchronization, +local Git repositories, LMDB state, and a durable relay identity. -```bash -nix flake check --no-build -``` - -#### Resource-safe module validation - -Nix copies path-valued build inputs into the store when they are forced. A Git -flake is materialized from its tracked files first, but a standalone path into a -working tree does not inherit that Git filtering. - -This matters when testing ngit-grasp's NixOS module locally. Importing -`nix/module.nix` is lazy by itself, but rendering an enabled service forces the -module-built package through `ExecStart`. The package's `src = ../.` then -resolves relative to that module. If the module was imported directly from a -working tree, Nix may recursively hash or copy ignored `target/`, `.git`, and -linked-worktree data while it appears to be evaluating the configuration. - -Use `inputs.ngit-grasp.nixosModules.default` from a Git-backed flake input, as -shown above. For local module changes, commit them to a temporary Git branch and -use that Git source, or replace `buildRustPackage` with a test stub that ignores -all build attributes so `src` remains unforced. Do not use a direct -working-tree module import for a test that enables an instance. - -Inspect the derivation plan before starting a build: - -```bash -nixos-rebuild dry-build --flake .#your-hostname -``` - -Multiple ngit-grasp instances should normally share one ngit-grasp package -derivation. Avoid service-level `ExecStart` overrides that force another flake -package or Rust toolchain. If distinct versions are intentional, build them -sequentially or on appropriately sized remote builders. For an initial local -build, constrain Nix while confirming the plan behaves as expected: - -```bash -nixos-rebuild build --flake .#your-hostname --max-jobs 1 --cores 2 -``` - ---- - -### Step 6: Deploy to Server - -Deploy the new configuration to your server: - -```bash -# Build and switch in one command (builds on server) -nixos-rebuild switch --flake .#your-hostname \ - --target-host user@server.example.com \ - --use-remote-sudo \ - --build-host user@server.example.com -``` - -**Alternative:** Build locally, then deploy: - -```bash -# Build locally -nixos-rebuild build --flake .#your-hostname - -# Deploy to server -nixos-rebuild switch --flake .#your-hostname \ - --target-host user@server.example.com \ - --use-remote-sudo -``` - -**Note:** Building locally requires your machine to trust the server's nix signing key. - ---- - -### Step 7: Verify Deployment - -SSH to the server and check the service: - -```bash -ssh user@server.example.com - -# Check service status -systemctl status ngit-grasp-production - -# View logs -journalctl -u ngit-grasp-production -f - -# Check if listening on port -ss -tlnp | grep 8082 -``` - ---- - -### Step 8: Test Functionality - -From your local machine, test the relay: - -```bash -# Test NIP-11 relay info -curl https://ngit.example.com -H "Accept: application/nostr+json" | jq - -# Test WebSocket connection -websocat wss://ngit.example.com -# Then type: ["REQ","test",{}] -# Should receive events - -# Test git clone (if you have repos) -git ls-remote https://ngit.example.com//.git -``` - ---- - -## Configuration Options - -### Required -- `enable` - Enable this instance -- `domain` - Domain where relay is hosted - -### Network -- `basePath` - Public URL mount path (default: `/`) -- `bindAddress` - IP to bind to (default: "127.0.0.1") -- `port` - Port to listen on (default: 7334) -- `trustedProxyCidrs` - Proxy networks allowed to provide the WebSocket client - IP (default: empty; forwarded headers ignored) - -### Storage -- `dataDir` - Base directory for data (default: /var/lib/ngit-grasp-{name}) -- `databaseBackend` - "lmdb" | "memory" (default: "lmdb") - -See [Upgrade Git family storage](upgrade-git-family-storage.md) before updating -an existing instance to a release that enables identifier-family storage. - -### Identity -- `relayName` - Relay name for NIP-11 (default: "{domain} grasp relay") -- `relayDescription` - Relay description -- `relayOwnerNsecFile` - Runtime secret file loaded as a systemd credential (recommended) -- `relayOwnerNsec` - Inline nsec (less secure) - -### Sync -- `syncBootstrapRelayUrl` - Bootstrap relay URL (optional) -- `syncDisableNegentropy` - Disable NIP-77 negentropy (default: false) -- `syncMaxBackoffSecs` - Max backoff for reconnection (default: 3600) -- `syncDisconnectCheckIntervalSecs` - Check interval (default: 60) -- `syncBaseBackoffSecs` - Base backoff time (default: 5) - -### Metrics -- `metricsEnabled` - Enable `/metrics` below the configured base path (default: true) -- `metricsConnectionPerIpAbuseThreshold` - Abuse threshold (default: 10) -- `metricsTopNRepos` - Number of top repos to track (default: 10) - -### Logging -- `logLevel` - "trace" | "debug" | "info" | "warn" | "error" (default: "info") - -### Security -- `user` - User to run as (default: "ngit-grasp-{name}") -- `group` - Group to run as (default: "ngit-grasp") - -See [nix/module.nix](../../nix/module.nix) for complete option definitions. - ---- - -## Systemd Service - -The NixOS module creates a systemd service: `ngit-grasp-{instance-name}` - -```bash -# Start/stop/restart -systemctl start ngit-grasp-production -systemctl stop ngit-grasp-production -systemctl restart ngit-grasp-production - -# Enable/disable autostart -systemctl enable ngit-grasp-production -systemctl disable ngit-grasp-production - -# View logs -journalctl -u ngit-grasp-production -f -journalctl -u ngit-grasp-production --since "1 hour ago" - -# Check status -systemctl status ngit-grasp-production -``` - ---- - -## Multiple Instances - -You can run multiple instances on the same server: - -```nix -services.ngit-grasp = { - production = { - enable = true; - domain = "ngit.example.com"; - port = 8082; - dataDir = "/persistent/ngit-production"; - }; - - staging = { - enable = true; - domain = "ngit-staging.example.com"; - port = 8083; - dataDir = "/persistent/ngit-staging"; - logLevel = "debug"; - }; -}; -``` - -Each instance: -- Runs as separate systemd service: `ngit-grasp-production`, `ngit-grasp-staging` -- Has its own user: `ngit-grasp-production`, `ngit-grasp-staging` -- Stores data in separate directory -- Can have different configuration - ---- - -## Troubleshooting - -### Service won't start - -**Check logs:** -```bash -journalctl -u ngit-grasp-production -n 50 -``` - -**Common issues:** -- Port already in use: Check with `ss -tlnp | grep 8082` -- Data directory permissions: Should be owned by service user -- Invalid nsec file: Check file exists and contains valid nsec - -### Can't connect via WebSocket - -**Check:** -- Service is running: `systemctl status ngit-grasp-production` -- Firewall allows connections: `nix-shell -p nmap --run "nmap -p 443 ngit.example.com"` -- Caddy is configured correctly: `systemctl status caddy` -- DNS resolves: `dig ngit.example.com` - -### Sync not working - -**Check logs for sync errors:** -```bash -journalctl -u ngit-grasp-production | grep -i sync -``` - -**Common issues:** -- Bootstrap relay URL incorrect or unreachable -- Network connectivity issues -- Bootstrap relay doesn't support negentropy (disable with `syncDisableNegentropy = true`) - -### High memory/CPU usage - -**Monitor metrics:** -```bash -curl http://localhost:8082/metrics -``` - -**Tune configuration:** -- Reduce `metricsTopNRepos` -- Increase `syncMaxBackoffSecs` -- Tune `syncMaxBackoffSecs` for your network conditions - ---- - -## Rollback - -If deployment fails, rollback to previous configuration: - -```bash -# On the server -nixos-rebuild switch --rollback - -# Or remotely -nixos-rebuild switch --rollback \ - --target-host user@server.example.com \ - --use-remote-sudo -``` - ---- - -## Upgrading - -To upgrade ngit-grasp: - -```bash -# Update flake input -nix flake update ngit-grasp - -# Review changes -git diff flake.lock - -# Commit -git add flake.lock -git commit -m "Update ngit-grasp" - -# Deploy -nixos-rebuild switch --flake .#your-hostname \ - --target-host user@server.example.com \ - --use-remote-sudo \ - --build-host user@server.example.com -``` - ---- - -## Security Hardening - -The NixOS module includes systemd hardening: - -- `NoNewPrivileges = true` - Prevents privilege escalation -- `ProtectSystem = "strict"` - Read-only filesystem except dataDir -- `ProtectHome = true` - No access to home directories -- `PrivateTmp = true` - Private /tmp -- `RestrictAddressFamilies` - Only allow needed network families -- `SystemCallFilter` - Restrict system calls - -Additional recommendations: - -1. **Use a runtime secret file instead of an inline key:** - ```nix - relayOwnerNsecFile = "/run/agenix/ngit-grasp-relay-owner-nsec"; - # NOT: relayOwnerNsec = "nsec1..."; # Ends up in nix store! - ``` - - The module exposes the file to ngit-grasp as the `relay_owner_nsec` - systemd credential. The key does not appear in `ExecStart` or the process - command line. ngit-grasp does not modify the external source file; keep its - ownership and permissions restricted through your secret manager. - -2. **Restrict data directory permissions:** - ```bash - chmod 750 /persistent/ngit-grasp - chown ngit-grasp-production:ngit-grasp /persistent/ngit-grasp - ``` - -3. **Use HTTPS (reverse proxy required):** - - ngit-grasp binds to localhost by default - - Use Caddy/nginx for TLS termination - - Caddy handles certificates automatically - -4. **Monitor logs regularly:** - ```bash - journalctl -u ngit-grasp-production --since today | grep -i error - ``` - ---- - -## Monitoring - -### Prometheus Metrics - -ngit-grasp exposes Prometheus metrics at `/metrics`: - -```bash -curl http://localhost:8082/metrics -``` - -See [Prometheus Setup](./prometheus-setup.md) for complete monitoring guide. - -### Basic Health Checks - -```bash -# Check if service is running -systemctl is-active ngit-grasp-production - -# Check if port is listening -nc -zv localhost 8082 - -# Check relay info -curl https://ngit.example.com -H "Accept: application/nostr+json" - -# Check disk usage -du -sh /persistent/ngit-grasp/* -``` - ---- - -## Related Documentation - -- [Configuration Reference](../reference/configuration.md) - All configuration options -- [NixOS Module](../../nix/module.nix) - Module source code -- [Example Configuration](../../nix/example-configuration.nix) - More examples -- [Prometheus Setup](./prometheus-setup.md) - Monitoring guide -- [Nix Flakes How-To](./nix-flakes.md) - Nix development environment - ---- - -*Part of the [ngit-grasp how-to guides](./)* +Kubernetes can run the container as a one-replica StatefulSet with a +ReadWriteOnce volume, but the repository does not yet ship or promise a Helm +chart. A container host or systemd service is simpler unless Kubernetes is an +existing operational requirement. diff --git a/docs/reference/README.md b/docs/reference/README.md index 96fc5ed..1be2ace 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -24,6 +24,13 @@ Reference documentation provides **factual, technical information** that you loo ## Available Reference Documentation +### [Deployment Contract](deployment-contract.md) + +Shared process, endpoint, persistence, identity, backup, single-writer, health, +and upgrade requirements for every supported hosting environment. + +--- + ### [Configuration](configuration.md) **Complete reference for all configuration options** diff --git a/nix/example-configuration.nix b/nix/example-configuration.nix index 8905cf2..dd29e77 100644 --- a/nix/example-configuration.nix +++ b/nix/example-configuration.nix @@ -2,7 +2,8 @@ # # Usage: # 1. Add to your server's flake.nix inputs: -# inputs.ngit-grasp.url = "github:DanConwayDev/ngit-grasp"; +# inputs.ngit-grasp.url = +# "git+https://gitnostr.com/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git"; # # 2. Import the module in your configuration: # imports = [ inputs.ngit-grasp.nixosModules.default ]; From 03d2b24bfe845265b3f644c60ae6334bb1376eb9 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 20 Aug 2026 19:37:03 +0000 Subject: [PATCH 4/5] ci(deploy): exercise container replacement The portable image and persistence test should run in CI rather than relying on an operator to have Docker installed locally. Add a focused ngit-ci workflow that obtains only the Docker client, curl, and jq through Nix, then builds the image, replaces the relay container, and verifies that its NIP-11 identity survives on the mounted volume. The workflow deliberately requires an explicitly mounted daemon socket and fails clearly when the operator has kept ngit-ci's secure default. This is intended for the disposable KVM guest daemon and does not weaken embedded-act hosts. Validated the workflow as YAML and listed its jobs and triggers with act 0.2.86 or newer from the ngit-ci development shell; git diff --check passes. The actual container job requires the remote runner's opted-in guest socket. --- .ngit/act/workflows/deployment_e2e.yaml | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .ngit/act/workflows/deployment_e2e.yaml diff --git a/.ngit/act/workflows/deployment_e2e.yaml b/.ngit/act/workflows/deployment_e2e.yaml new file mode 100644 index 0000000..7eff82d --- /dev/null +++ b/.ngit/act/workflows/deployment_e2e.yaml @@ -0,0 +1,38 @@ +# ngit-ci currently evaluates push path filters but not pull-request path +# filters, so PRs run this workflow unconditionally. +on: + push: + paths: + - ".dockerignore" + - "Cargo.lock" + - "Cargo.toml" + - "Dockerfile" + - "build.rs" + - "compose*.yaml" + - "deploy/**" + - "scripts/test-container-deployment.sh" + - "src/**" + pull_request: + +name: Container deployment e2e + +jobs: + container-deployment: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + - name: Require the disposable guest container daemon + run: | + if [[ ! -S /var/run/docker.sock ]]; then + echo "container daemon socket is not available" >&2 + echo "this workflow requires an ngit-ci operator opt-in" >&2 + exit 1 + fi + - name: Build, replace, and verify the container + run: | + nix shell nixpkgs#docker-client nixpkgs#curl nixpkgs#jq \ + --command scripts/test-container-deployment.sh From b1c61474749ea8a04f89cb9e359c5bb6a9ca4e3e Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 20 Aug 2026 20:16:47 +0000 Subject: [PATCH 5/5] test(grasp06): recognize actual missing endpoints The GRASP-06 rejection helper treated any 404 digits anywhere in git stderr as evidence that the smart-HTTP endpoint was absent. A randomly generated repository UUID containing 404b therefore turned a valid server-side ref rejection into a false test failure. Match transport-specific Git missing-repository diagnostics and cover both the accepted phrases and the observed URL regression. This changes only audit classification; relay behavior and the GRASP-06 contract are deliberately unchanged. Validated with the focused grasp-audit unit tests, cargo fmt --check, and the formerly failing grasp06_pr_hosting integration test against a spawned relay. --- grasp-audit/src/specs/grasp06/prs_endpoint.rs | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/grasp-audit/src/specs/grasp06/prs_endpoint.rs b/grasp-audit/src/specs/grasp06/prs_endpoint.rs index f75b02d..46d77fd 100644 --- a/grasp-audit/src/specs/grasp06/prs_endpoint.rs +++ b/grasp-audit/src/specs/grasp06/prs_endpoint.rs @@ -699,7 +699,7 @@ fn git_push(cwd: &Path, url: &str, refname: &str) -> std::io::Result Result<( } let stderr = String::from_utf8_lossy(&out.stderr); - // Match git's two common 404 phrasings. `to_lowercase` keeps it robust to - // future git releases tweaking capitalisation; the substrings are stable. - let lower = stderr.to_lowercase(); - if lower.contains("not found") || lower.contains("404") { + if stderr_indicates_missing_prs_endpoint(&stderr) { return Err(format!( "Push to {} (ref={}) failed with a 404 — the /prs/ endpoint is not implemented. \ GRASP-06 06.md line 11 requires the endpoint to be reachable as an empty bare \ @@ -734,3 +731,39 @@ fn check_push_rejected_not_404(cwd: &Path, url: &str, refname: &str) -> Result<( Ok(()) } + +/// Recognize Git's missing-smart-HTTP-endpoint diagnostics without treating +/// arbitrary `404` digits in an echoed URL as an HTTP status. +fn stderr_indicates_missing_prs_endpoint(stderr: &str) -> bool { + stderr.to_lowercase().lines().any(|line| { + let line = line.trim(); + line.contains("requested url returned error: 404") + || line == "remote: repository not found." + || (line.starts_with("fatal: repository ") && line.ends_with(" not found")) + }) +} + +#[cfg(test)] +mod tests { + use super::stderr_indicates_missing_prs_endpoint; + + #[test] + fn missing_endpoint_detection_accepts_git_transport_diagnostics() { + for stderr in [ + "fatal: unable to access 'https://example.test/repo.git/': The requested URL returned error: 404", + "fatal: repository 'https://example.test/repo.git/' not found", + "remote: Repository not found.", + ] { + assert!(stderr_indicates_missing_prs_endpoint(stderr), "{stderr}"); + } + } + + #[test] + fn missing_endpoint_detection_ignores_404_digits_in_rejection_url() { + let stderr = "remote: ERR GRASP-06: only pushes to refs/nostr/ are accepted\n\ +fatal: the remote end hung up unexpectedly\n\ +error: failed to push some refs to 'http://127.0.0.1/prs/npub/audit-probe-6218-404b.git'"; + + assert!(!stderr_indicates_missing_prs_endpoint(stderr)); + } +}