Skip to content
infra NATS Server Setup — Design Spec
Login

NATS Server Setup — Design Spec

Date: 2026-03-15 Status: Approved Context: Infrastructure foundation for Fossil NATS Sync (Phase E — Leaf Agent)


Overview

Configure a NATS 2.10 server with JetStream on the Hetzner VPS as the messaging hub for the Fossil NATS Sync project. The server must be reachable by both Tailscale-connected leaf nodes (MacBook, Mac Mini) and external public clients. Configuration is version-controlled in a new infra Fossil repo for reproducible setup and future cluster expansion.


Architecture

External clients / leaf agents
        │
        │ tls://sync.craftdesign.group:4222
        ▼
nats-server 2.10 (systemd, linux/arm64)
  listen: 0.0.0.0:4222  (TLS — external + Tailscale + local admin)
    │
    ├── /var/nats/jetstream   JetStream persistent storage
    ├── /var/nats/jwt         JWT resolver store
    └── /var/nats/tls         TLS cert + key (acme.sh managed)

Local admin tools (nsc, nats CLI) connect to tls://localhost:4222 --tlsskipverify

Single listener: NATS 2.10 does not support two independent TCP listeners with different TLS policies in one server process. All connections — external, Tailscale, and local admin — use the single TLS listener on port 4222. Local admin tools (nsc push, nats CLI) connect to tls://localhost:4222 with --tlsskipverify (acceptable for loopback connections where network interception is impossible).


NATS Version

Target version: 2.10.x (latest 2.10 stable at install time)

install.sh pins a NATS_VERSION variable (e.g., 2.10.22) and downloads the linux-arm64 binary:

https://github.com/nats-io/nats-server/releases/download/v${NATS_VERSION}/nats-server-v${NATS_VERSION}-linux-arm64.tar.gz

The Dockerfile pins the same minor version: FROM nats:2.10-alpine. Both are updated together when upgrading.


Config Repo (infra)

New Fossil repo: /var/fossil/repos/infra.fossil, browsable at fossil.craftdesign.group/infra/.

infra/
├── nats/
│   ├── nats-server.conf.template  # Config template (contains $OPERATOR_JWT placeholder)
│   ├── nats.service               # Systemd unit
│   ├── install.sh                 # Phase 1: binary, dirs, TLS, unit file (does NOT start service)
│   ├── bootstrap-creds.sh         # Phase 2: one-time nsc operator/account/user setup
│   ├── start.sh                   # Phase 3: render config, enable and start service, nsc push
│   ├── provision-streams.sh       # Phase 4: idempotent JetStream stream creation
│   ├── Dockerfile                 # Docker surface for future cluster nodes
│   ├── creds/
│   │   └── README.md              # How to regenerate operator/account/user creds with nsc
│   └── README.md
└── README.md

creds/ is listed in .fossil-ignore — NKey seeds and JWT files are never committed. /etc/nats/nats-server.conf (the rendered live config) is also never committed.


Install Phases (explicit ordering)

Phase 1 — install.sh: installs binary, creates dirs, obtains TLS cert, copies config template and unit file. Does not start the service (operator JWT not yet available).

Phase 2 — bootstrap-creds.sh: one-time nsc setup. Creates operator, accounts, users. Writes /etc/nats/operator.env containing OPERATOR_JWT=<value>.

Phase 3 — start.sh: renders config template → live config, enables and starts the service, pushes JWTs to the running resolver.

Phase 4 — provision-streams.sh: creates JetStream streams after the server is running. Idempotent and non-interactive.


TLS Provisioning

Mechanism: acme.sh with Cloudflare DNS-01 challenge

Traefik already holds ports 80/443, making HTTP-01 challenges impossible for non-Coolify services. DNS-01 via the Cloudflare API sidesteps this completely.

install.sh handles TLS setup:

# Install acme.sh (if not present)
curl https://get.acme.sh | sh -s [email protected]

# cf-token.env must already exist at /etc/nats/cf-token.env (operator places it before running install.sh)
# It exports: CF_Token="<cloudflare-api-token>" with Zone:DNS:Edit permission for craftdesign.group
source /etc/nats/cf-token.env

~/.acme.sh/acme.sh --issue --dns dns_cf \
  -d sync.craftdesign.group \
  --key-file        /var/nats/tls/sync.craftdesign.group.key \
  --fullchain-file  /var/nats/tls/sync.craftdesign.group.crt \
  --reloadcmd       "systemctl kill --signal=SIGHUP nats"

# Set ownership so nats-server (running as nats user) can read the key
chown nats:nats /var/nats/tls/sync.craftdesign.group.key /var/nats/tls/sync.craftdesign.group.crt
chmod 0640 /var/nats/tls/sync.craftdesign.group.key /var/nats/tls/sync.craftdesign.group.crt

install.sh Responsibilities (idempotent, Phase 1)

  1. Verify NATS_VERSION is set; download and install nats-server binary to /usr/local/bin/nats-server
  2. Create nats system user (no login shell) if not present
  3. Create directories:
    • /var/nats/jetstream — owned by nats:nats, mode 0750
    • /var/nats/jwt — owned by nats:nats, mode 0750
    • /var/nats/tls — owned by nats:nats, mode 0750
    • /etc/nats/ — owned by root, mode 0755
  4. Verify /etc/nats/cf-token.env exists and is mode 0600; fail with clear error if not
  5. Copy nats-server.conf.template to /etc/nats/nats-server.conf.template
  6. Install acme.sh if not present; issue TLS cert via DNS-01 (skips if cert already valid); set ownership nats:nats on cert and key files
  7. Install nats.service to /etc/systemd/system/; systemctl daemon-reload
  8. Print: "Phase 1 complete. Run bootstrap-creds.sh next."

The service is not started by install.sh.


bootstrap-creds.sh Responsibilities (Phase 2, one-time)

Requires nsc CLI (install via curl -fsSL https://install.nats.io/nsc | sh).

No server connection is needed at this stage — nsc works purely locally.

nsc add operator --name fossil-sync

# Use localhost TLS for all local nsc/nats CLI operations
nsc edit operator --account-jwt-server-url tls://localhost:4222

nsc add account --name edgesync
nsc add account --name go-libfossil

# Admin user per account — full pub/sub for local stream provisioning only
nsc add user --account edgesync     --name admin --allow-pub '>' --allow-sub '>'
nsc add user --account go-libfossil --name admin --allow-pub '>' --allow-sub '>'

# Bridge user — scoped to project subjects
nsc add user --account edgesync \
  --name bridge \
  --allow-pub  'fossil.edgesync.>' \
  --allow-sub  'fossil.edgesync.>'

nsc add user --account go-libfossil \
  --name bridge \
  --allow-pub  'fossil.go-libfossil.>' \
  --allow-sub  'fossil.go-libfossil.>'

# Generate .creds files
nsc generate creds --account edgesync     --name bridge > creds/edgesync-bridge.creds
nsc generate creds --account go-libfossil --name bridge > creds/go-libfossil-bridge.creds
nsc generate creds --account edgesync     --name admin  > creds/edgesync-admin.creds
nsc generate creds --account go-libfossil --name admin  > creds/go-libfossil-admin.creds

# Extract operator JWT using nsc generate config (the supported extraction method)
OPERATOR_JWT=$(nsc generate config --nats-resolver 2>/dev/null \
  | grep '^operator:' \
  | awk '{print $2}')

echo "OPERATOR_JWT=${OPERATOR_JWT}" > /etc/nats/operator.env
chmod 0600 /etc/nats/operator.env

echo "Phase 2 complete. Run start.sh next."

nsc local store lives at ~/.local/share/nats/nsc/ (default). creds/README.md documents the full nsc command sequence for recreating the credential tree on a fresh machine.

Adding a new leaf node (post-setup)

nsc add user --account <project> \
  --name leaf-<hostname> \
  --allow-pub  'fossil.<project>.igot,fossil.<project>.file,fossil.<project>.delta' \
  --allow-sub  'fossil.<project>.gimme,fossil.<project>.file,fossil.<project>.meta,fossil.<project>.events'

nsc generate creds --account <project> --name leaf-<hostname> > leaf-<hostname>.creds

nsc push --all --server tls://localhost:4222 --tlsskipverify

# Distribute .creds to the leaf node via scp over Tailscale

start.sh Responsibilities (Phase 3)

Renders config from template (preserving the template file), starts the service, pushes JWTs.

source /etc/nats/operator.env   # loads OPERATOR_JWT

# Render template → live config (template file is never modified)
envsubst < /etc/nats/nats-server.conf.template > /etc/nats/nats-server.conf

systemctl enable --now nats

# Wait for server to be ready
sleep 2

# Push account JWTs to the running resolver
nsc push --all --server tls://localhost:4222 --tlsskipverify

echo "Phase 3 complete. Run provision-streams.sh next."

Re-running start.sh is safe: envsubst always reads from the .template file.


provision-streams.sh Responsibilities (Phase 4, idempotent)

Uses tls://localhost:4222 --tlsskipverify and admin creds. The --defaults flag suppresses all interactive prompts.

NATS_SERVER="tls://localhost:4222"
NATS_TLS_FLAGS="--tlsskipverify"

for PROJECT in edgesync go-libfossil; do
  STREAM=$(echo "$PROJECT" | tr '[:lower:]' '[:upper:]' | tr '-' '_')
  CREDS="creds/${PROJECT}-admin.creds"

  if nats stream info "$STREAM" \
      --server "$NATS_SERVER" $NATS_TLS_FLAGS \
      --creds  "$CREDS" &>/dev/null; then
    echo "Stream $STREAM already exists, skipping."
    continue
  fi

  nats stream add "$STREAM" \
    --server    "$NATS_SERVER" $NATS_TLS_FLAGS \
    --creds     "$CREDS" \
    --subjects  "fossil.${PROJECT}.>" \
    --retention limits \
    --max-age   168h \
    --max-bytes 1073741824 \
    --storage   file \
    --replicas  1 \
    --defaults

  echo "Stream $STREAM created."
done

NATS Server Config Template (nats-server.conf.template)

server_name: vps-nbg1

listen: 0.0.0.0:4222

tls {
  cert_file: "/var/nats/tls/sync.craftdesign.group.crt"
  key_file:  "/var/nats/tls/sync.craftdesign.group.key"
}

jetstream {
  store_dir: "/var/nats/jetstream"
}

# JWT resolver — accounts pushed via `nsc push --all`
resolver: {
  type:         full
  dir:          "/var/nats/jwt"
  allow_delete: false
  interval:     "2m"
}

# Operator JWT — substituted by start.sh via envsubst
operator: $OPERATOR_JWT

# Cluster placeholder — populate when adding a second node
# cluster {
#   name: fossil-sync
#   listen: 0.0.0.0:6222
#   routes: [ nats://node2.internal:6222 ]
# }

Systemd Unit (nats.service)

[Unit]
Description=NATS Server
After=network-online.target
Wants=network-online.target

[Service]
User=nats
Group=nats
ExecStart=/usr/local/bin/nats-server -c /etc/nats/nats-server.conf
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
LimitNOFILE=1048576

[Install]
WantedBy=multi-user.target

Auth & Security

NKeys + JWT (three-tier)

Operator: fossil-sync
├── Account: edgesync
│   ├── User: admin    (pub/sub > — local provisioning on VPS only)
│   ├── User: bridge   (pub+sub fossil.edgesync.>)
│   └── User: leaf-<hostname>
└── Account: go-libfossil
    ├── User: admin
    ├── User: bridge   (pub+sub fossil.go-libfossil.>)
    └── User: leaf-<hostname>

Future migration path

Account names (edgesync, go-libfossil) will be replaced with opaque IDs when multi-tenant scale requires it. The JWT resolver supports this without protocol changes — it is a config and tooling update only.

Subject permissions

Leaf user:
  publish:   fossil.<project>.igot, fossil.<project>.file, fossil.<project>.delta
  subscribe: fossil.<project>.gimme, fossil.<project>.file, fossil.<project>.meta, fossil.<project>.events

Bridge user:
  publish:   fossil.<project>.>
  subscribe: fossil.<project>.>

Admin user:
  publish:   >
  subscribe: >
  (used only on VPS via tls://localhost:4222 --tlsskipverify)

Dockerfile

Same minor version as systemd install. The $OPERATOR_JWT placeholder must be rendered before building — the Dockerfile is intended for use with a pre-rendered nats-server.conf (not the .template file). Build workflow for Docker deployments:

source /etc/nats/operator.env
envsubst < nats-server.conf.template > nats-server.conf-rendered
docker build --build-arg CONF=nats-server.conf-rendered -t fossil-nats:2.10 .
FROM nats:2.10-alpine
ARG CONF=nats-server.conf-rendered
COPY ${CONF} /etc/nats/nats-server.conf
# TLS certs and creds are mounted at runtime — never baked into the image
CMD ["nats-server", "-c", "/etc/nats/nats-server.conf"]

DNS & Firewall

DNS

Add an A record in Cloudflare for craftdesign.group:

Name Type Value Proxy
sync A <hetzner-public-ip> DNS only (grey cloud)

Must be grey cloud — NATS is raw TCP; Cloudflare cannot proxy it.

Hetzner Cloud Firewall

Add an inbound rule to the VPS firewall:

Protocol Port Source
TCP 4222 0.0.0.0/0, ::/0

JetStream Streams

Stream Subjects Retention Storage
EDGESYNC fossil.edgesync.> Limits (7 days / 1 GB) File
GO_LIBFOSSIL fossil.go-libfossil.> Limits (7 days / 1 GB) File

Operational Reference

Service management

sudo systemctl status nats
sudo journalctl -u nats -f
sudo systemctl kill --signal=SIGHUP nats   # reload config/certs — no connection drop

Verify public connectivity

nats server ping --server tls://sync.craftdesign.group:4222
nats account info --server tls://sync.craftdesign.group:4222 --creds creds/edgesync-bridge.creds

Verify local admin connectivity

nats server ping --server tls://localhost:4222 --tlsskipverify
nats stream ls   --server tls://localhost:4222 --tlsskipverify --creds creds/edgesync-admin.creds

Re-push JWTs after credential changes

nsc push --all --server tls://localhost:4222 --tlsskipverify

Out of Scope