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.
This commit is contained in:
DanConwayDev
2026-08-20 19:38:04 +00:00
parent 47bfc2015d
commit 37d81f3713
11 changed files with 671 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.direnv
.env
.relay-owner.nsec
data
result
result-*
target
work
worktrees
**/target
+55
View File
@@ -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"]
+28
View File
@@ -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:
+46
View File
@@ -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}"
+21
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
{$NGIT_DOMAIN} {
reverse_proxy ngit-grasp:7334
}
+38
View File
@@ -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 "$@"
+136
View File
@@ -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.
+139
View File
@@ -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.
+95
View File
@@ -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"
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
set -eu
usage() {
echo "Usage: $0 <public-base-url>" >&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}"