Self-Hosting — Signet Docs

Docs / Infrastructure

Self-Hosting

Self-hosting the Signet daemon.

Self-Hosting the Signet Daemon

The Signet Daemon is a Hono HTTP server that runs as a background service, providing Memory storage, search, and the web Dashboard. By default it binds to localhost:3850 and requires no authentication. This document covers everything needed to run it persistently, expose it to a team, and keep it healthy in production.

Overview

The daemon process is a single Bun-targeted binary at packages/daemon/dist/daemon.js. It owns two things: the SQLite database at $SIGNET_WORKSPACE/memory/memories.db and the config directory at $SIGNET_WORKSPACE/. Everything else — file watching, the memory pipeline, the dashboard — runs inside that one process.

Environment variables control the network address:

  • SIGNET_HOST — daemon host for local calls and default bind address (default: 127.0.0.1)
  • SIGNET_BIND — explicit bind address override (default: SIGNET_HOST)
  • SIGNET_PORT — port to listen on (default: 3850)
  • SIGNET_PATH — override the data directory (default: $SIGNET_WORKSPACE/)
  • SIGNET_LOG_FILE — optional explicit daemon log file path
  • SIGNET_SQLITE_PATH — macOS explicit override for a custom libsqlite3.dylib

Bun is a hard requirement. The daemon uses bun:sqlite directly and will refuse to start under Node.

On macOS, SIGNET_SQLITE_PATH is authoritative when set. If it points at a missing file, Signet refuses fallback so the misconfiguration is obvious. If it is unset, Signet checks $SIGNET_WORKSPACE/libsqlite3.dylib, where the workspace resolves from SIGNET_PATH, then ~/.config/signet/workspace.json, then the default ~/.agents, and then standard Homebrew SQLite paths so sqlite-vec can load before the first Bun connection opens.

Docker Compose (first-party)

Signet now ships a first-party Docker stack in deploy/docker/ for self-hosted daemon deployments:

cd deploy/docker
cp .env.example .env
docker compose up -d --build

This stack includes:

  • signet service (daemon runtime)
  • caddy reverse proxy (TLS-capable, SSE-safe)
  • named Docker volumes for persistent workspace and proxy state

The container entrypoint creates $SIGNET_WORKSPACE/agent.yaml with auth.mode: team on first start when no config exists. To generate an initial admin token from the auth secret, run:

docker compose exec signet \
  bun /app/deploy/docker/scripts/create-token.mjs --role admin --sub bootstrap

The command prints a bearer token to stdout. Store it securely and use it for admin API calls (for example /api/auth/token to mint scoped operator/agent tokens).

By default, Compose publishes Caddy on ports 80/443. Override SIGNET_HTTP_PORT and SIGNET_HTTPS_PORT in .env when those ports are already occupied.

Running as a Service

The easiest path is the CLI:

signet daemon install   # installs and starts the service
signet daemon uninstall # stops and removes it

The install command detects the platform and writes the appropriate service definition, then starts it. Under the hood, it generates the unit file from the actual runtime path (which bun) and the installed daemon path, so the service file always reflects the current installation.

systemd (Linux)

The CLI writes a user-level unit file to ~/.config/systemd/user/signet.service with Restart=on-failure and RestartSec=5. The unit looks like this:

[Unit]
Description=Signet Daemon
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/bun /path/to/daemon.js
Environment=SIGNET_PORT=3850
Environment=SIGNET_PATH=/home/you/.agents
WorkingDirectory=/home/you/.agents
Restart=on-failure
RestartSec=5
StandardOutput=append:/home/you/.agents/.daemon/logs/daemon.out.log
StandardError=append:/home/you/.agents/.daemon/logs/daemon.err.log

[Install]
WantedBy=default.target

To manage it manually after installation:

systemctl --user status signet.service
systemctl --user restart signet.service
journalctl --user -u signet.service -f

For the service to survive logout on headless servers, you need lingering enabled for your user:

loginctl enable-linger $USER

launchd (macOS)

The CLI writes a plist to ~/Library/LaunchAgents/ai.signet.daemon.plist with KeepAlive and RunAtLoad set to true, so it starts at login and restarts on crash. Stdout and stderr go to $SIGNET_WORKSPACE/.daemon/logs/.

launchctl list ai.signet.daemon   # check status
launchctl unload ~/Library/LaunchAgents/ai.signet.daemon.plist
launchctl load ~/Library/LaunchAgents/ai.signet.daemon.plist

The generated plist hard-codes the Bun path as /opt/homebrew/bin/bun for Apple Silicon. If Bun is installed elsewhere, either re-run signet daemon install after updating PATH, or edit the plist manually.

Team Mode Deployment

By default the daemon runs in local mode: no authentication, no rate limiting, localhost only. For shared deployments you switch to team mode, which requires a bearer token on every request.

Add an auth block to $SIGNET_WORKSPACE/agent.yaml:

auth:
  mode: team

Then restart the daemon. On first start with a non-local mode, the daemon generates a 32-byte HMAC secret at $SIGNET_WORKSPACE/.daemon/auth-secret with permissions 0600. All tokens are signed against this secret. Rotating the secret invalidates all existing tokens.

Generating tokens

Tokens are created via the API. In team mode you need an existing admin token to create more; on first setup, you can temporarily switch to hybrid mode (which grants full access from localhost without a token) to bootstrap.

curl -s -X POST http://localhost:3850/api/auth/token \
  -H "Content-Type: application/json" \
  -d '{
    "role": "agent",
    "sub": "ci-runner",
    "ttlSeconds": 2592000
  }'

The response contains a token field. Distribute this string to the consumer. All subsequent requests must include it as a bearer token:

Authorization: Bearer <token>

Tokens carry a sub (subject identifier), a role, a scope, and an expiry. The token format is {base64url(claims)}.{base64url(hmac-sha256)} — no external JWT library, no key infrastructure needed beyond the secret file.

Roles and permissions

Four roles are supported. Each grants a fixed set of permissions:

RolePermissions
adminall, including token creation and admin operations
operatorremember, recall, modify, forget, recover, documents, connectors, diagnostics, analytics
agentremember, recall, modify, forget, recover, documents
readonlyrecall only

Scope restrictions can narrow a token further. A token scoped to project: acme will be rejected when accessing memories tagged to a different project. Admin tokens bypass all scope checks.

# Scoped token for a specific agent
curl -s -X POST http://localhost:3850/api/auth/token \
  -H "Authorization: Bearer <admin-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "role": "agent",
    "sub": "claude-code:acme",
    "scope": { "project": "acme" },
    "ttlSeconds": 86400
  }'

Default token TTL is 7 days. Session tokens (issued by hook endpoints) are 24 hours. Both are configurable in agent.yaml:

auth:
  mode: team
  defaultTokenTtlSeconds: 604800   # 7 days
  sessionTokenTtlSeconds: 86400    # 24 hours

Rate limits

In team and hybrid modes, destructive operations are rate-limited per actor (the token’s sub field) using an in-memory sliding window. Defaults:

OperationWindowMax requests
forget60s30
modify60s60
batchForget60s5
forceDelete60s3
admin60s10

These can be overridden in agent.yaml:

auth:
  mode: team
  rateLimits:
    forget:
      windowMs: 60000
      max: 10

Rate limit state is in-memory and resets on daemon restart.

Hybrid Mode

hybrid mode gives you the convenience of local mode for local tooling while requiring tokens from remote clients. Requests arriving with a host header of localhost, 127.0.0.1, or ::1 bypass authentication entirely and receive full admin-equivalent access. All other origins must present a valid bearer token.

auth:
  mode: hybrid

This is a practical default for development machines where you want the local dashboard and CLI to work without tokens, but also want to call the daemon from CI pipelines or remote agents. Note the localhost bypass is checked against the Host header, not the TCP peer address, so it is trusting the client to report its own address honestly. For anything exposed to untrusted networks, use team mode with a reverse proxy.

Network Configuration

The daemon is HTTP-only. TLS must be terminated upstream.

To bind to all interfaces (required when using a reverse proxy on the same host, or when hosting on a VM):

SIGNET_HOST=0.0.0.0 SIGNET_PORT=3850 bun daemon.js

Or set it in the systemd unit:

Environment=SIGNET_HOST=0.0.0.0
Environment=SIGNET_PORT=3850

Never expose port 3850 directly to the internet in local or hybrid mode. Use team mode plus a reverse proxy that terminates TLS.

Reverse Proxy

nginx

server {
    listen 443 ssl;
    server_name signet.example.com;

    ssl_certificate     /etc/letsencrypt/live/signet.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/signet.example.com/privkey.pem;

    location / {
        proxy_pass         http://127.0.0.1:3850;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;

        # SSE support
        proxy_buffering    off;
        proxy_read_timeout 3600s;
    }
}

Caddy

signet.example.com {
    reverse_proxy localhost:3850 {
        flush_interval -1
    }
}

Caddy handles TLS automatically via Let’s Encrypt. The flush_interval -1 directive is needed for the /sse endpoint to stream events correctly without buffering.

In both cases, set auth.mode: team in agent.yaml before exposing the daemon. Hybrid mode’s localhost bypass does not apply when the request arrives through a proxy — the Host header will be the public hostname, not localhost.

Backup and Restore

All persistent state lives in two places:

  • $SIGNET_WORKSPACE/memory/memories.db — the SQLite database
  • $SIGNET_WORKSPACE/ — config files, secrets, skills

The database runs in WAL mode, which makes it safe to copy while the daemon is running. A simple backup:

cp $SIGNET_WORKSPACE/memory/memories.db /backup/memories-$(date +%Y%m%d).db

For a full backup including config and the auth secret:

rsync -a $SIGNET_WORKSPACE/ /backup/agents-$(date +%Y%m%d)/

The auth secret lives at $SIGNET_WORKSPACE/.daemon/auth-secret (binary, 32 bytes, mode 0600). Back it up with your config. Losing it invalidates all issued tokens.

To restore, stop the daemon, copy the files back, and restart:

systemctl --user stop signet.service
rsync -a /backup/agents-20260101/ $SIGNET_WORKSPACE/
systemctl --user start signet.service

For scheduled backups, a daily cron that copies the database and rotates old copies is sufficient. Incremental WAL checkpoints happen automatically during normal operation.

Monitoring

Health check

GET /health

Returns HTTP 200 with JSON { status: "healthy", uptime: <seconds>, pid: <int> }. Use this for load balancer health checks and uptime monitors. It has no authentication requirement regardless of auth mode.

Diagnostics

GET /api/diagnostics

Returns a scored health report covering database integrity, FTS consistency, embedding provider status, memory pipeline state, and mutation health. Requires the diagnostics permission (available to admin and operator roles).

Analytics

GET /api/analytics/usage    # request counts by route and method
GET /api/analytics/errors   # pipeline errors, filterable by stage
GET /api/analytics/latency  # p50/p95/p99 latency histograms
GET /api/analytics/logs     # recent structured log entries

All analytics endpoints require the analytics permission. Data is collected in-memory and resets on daemon restart. For durable metrics, scrape these endpoints on a schedule and push to your monitoring stack.

A quick check to confirm the daemon is healthy and the pipeline is running:

curl -s http://localhost:3850/health | jq .
curl -s http://localhost:3850/api/diagnostics | jq '.score'

Troubleshooting

Daemon won’t start

Check whether the port is already in use:

ss -tlnp | grep 3850
lsof -i :3850

If another process holds the port, either stop it or change SIGNET_PORT. If the daemon starts then immediately exits, check the log file:

tail -50 $SIGNET_WORKSPACE/.daemon/logs/daemon.out.log
tail -50 $SIGNET_WORKSPACE/.daemon/logs/daemon.err.log

The most common cause is Bun not being found. The systemd unit and launchd plist hard-code the Bun path detected at install time. If Bun was moved or reinstalled, re-run signet daemon install to regenerate the service file.

Auth issues

If requests return 401 authentication required, confirm the auth mode:

curl -s http://localhost:3850/api/auth/status | jq .mode

If the mode is team or hybrid, the request needs a valid bearer token. Tokens expire — check the exp field by base64-decoding the first segment of the token:

TOKEN="<your-token>"
echo "${TOKEN%%.*}" | base64 -d 2>/dev/null | jq .exp

Compare the epoch value against date +%s. If expired, generate a new token. If you’ve lost all admin tokens, temporarily set auth.mode: local in agent.yaml, restart the daemon, generate a new admin token, then switch back to team mode.

Pipeline not processing

First check whether extraction is intentionally disabled:

memory:
  pipelineV2:
    enabled: false
    extraction:
      provider: none

If provider: none is set, or enabled: false, the pipeline staying idle is expected. This is the recommended configuration for VPS installs that should not make background LLM calls.

If extraction is enabled and using Ollama, confirm the server is running and the model is pulled:

curl -s http://localhost:11434/api/tags | jq '.models[].name'

The recommended local floor is qwen3:4b. If it is not listed:

ollama pull qwen3:4b

If Ollama is running but the pipeline is still idle, check whether it is enabled in agent.yaml:

memory:
  pipelineV2:
    enabled: true

shadowMode: true means the pipeline extracts but does not write to the database — useful for testing, but memories will not persist. Set it to false for production operation.

Cost warning: the intended extraction setups are Claude Code on Haiku, Codex CLI on GPT Mini with a Pro/Max subscription, or local Ollama. Remote API extraction can create extreme fees quickly if left running in the background.

High memory usage

If the daemon process grows unboundedly, retention is likely not running or the embedding index has accumulated too many orphaned entries. Check diagnostics:

curl -s http://localhost:3850/api/diagnostics | jq '{score, memory, mutation}'

A low score in the memory domain often means retention sweeps have stopped. Restarting the daemon triggers a fresh sweep on startup. If the issue persists, check for stale job leases in the pipeline queue — the GET /api/diagnostics response includes queue depth and lease counts.

For embeddings specifically: each memory stores a vector, and deleted memories leave tombstones until the next retention run. If tombstone count is high, trigger a repair manually:

curl -s -X POST http://localhost:3850/api/repair/requeue-dead-jobs \
  -H "Authorization: Bearer <admin-token>"