Skip to content

Deployment

Rafael Gumieri edited this page Jun 15, 2026 · 5 revisions

Deployment

Nenya supports three deployment targets: bare metal (systemd), container (Podman/Docker), and Kubernetes (Helm).

Bare Metal (systemd)

Installation

The install script sets up the binary, example config, and systemd unit:

# Latest version
curl -fsSL https://raw.githubusercontent.com/gumieri/nenya/main/install.sh | sudo sh

# Pinned version
curl -fsSL https://raw.githubusercontent.com/gumieri/nenya/main/install.sh | sudo sh -s -- -v 0.1.0

# Dry run (audit before installing)
curl -fsSL https://raw.githubusercontent.com/gumieri/nenya/main/install.sh | sh -s -- --dry-run

Or build from source:

git clone https://github.com/gumieri/nenya.git
cd nenya
go build -o nenya ./cmd/nenya/

Configuration Directory

Nenya supports two configuration modes:

Directory mode (default):

/etc/nenya/
├── config.json               # single config file (mutually exclusive with config.d/)
├── config.d/                  # directory mode (takes priority over config.json)
│   ├── 00-server.json        # server, governance, bouncer, compaction
│   ├── 10-providers.json     # provider URL or auth overrides
│   └── 20-agents.json        # agent definitions with fallback chains
└── secrets.json               # loaded via systemd credentials

Single file mode:

./nenya --config /path/to/config.json

Environment variables:

NENYA_CONFIG_DIR=/etc/nenya ./nenya           # directory mode
NENYA_CONFIG_FILE=/path/to/config.json ./nenya  # single file mode

Multi-File Merge Rules

Field Type Behavior
agents (map) Per-key merge — later files add or override individual agents
providers (map) Per-key merge — later files add or override individual providers
mcp_servers (map) Per-key merge
server, governance, bouncer, etc. (struct) Last file wins

Note: config.d/ and config.json are mutually exclusive — if config.d/ exists and is non-empty, config.json is ignored.

Systemd Service

The service file is installed at /etc/systemd/system/nenya.service:

[Unit]
Description=Nenya AI Gateway & Bouncer
Requires=nenya.socket
After=nenya.socket

[Service]
Type=simple
DynamicUser=yes
ExecStart=/usr/bin/nenya
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s

# Capability and privilege restrictions
NoNewPrivileges=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes

# Filesystem and process isolation
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RemoveIPC=yes
UMask=0077

# Network restrictions
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

# Memory protection
MemoryDenyWriteExecute=yes

# Allow mlock for secure memory (tokens stored in locked RAM)
LimitMEMLOCK=infinity

# Disable core dumps to prevent locked memory from reaching disk
LimitCORE=0

# Syscall filtering (seccomp)
SystemCallFilter=@system-service
SystemCallFilter=~@mount @privileged @raw-io @reboot @swap

# Secrets loaded via systemd credential
LoadCredential=secrets:/etc/nenya/secrets.json

[Install]
WantedBy=multi-user.target

Socket Activation

Nenya supports systemd socket activation for zero-downtime restarts:

# /etc/systemd/system/nenya.socket
[Unit]
Description=Nenya AI Gateway Socket

[Socket]
ListenStream=8080
Accept=no

[Install]
WantedBy=sockets.target

Enable and start:

sudo systemctl enable --now nenya.socket
sudo systemctl enable --now nenya.service

The socket holds the listening port (8080) and automatically activates nenya.service on the first connection. When the service restarts, connections queue in the socket and the new process inherits the file descriptor — no dropped requests.

Secrets

# Single file
sudo mkdir -p /etc/nenya
sudo tee /etc/nenya/secrets.json << 'EOF'
{
  "client_token": "nk-$(openssl rand -hex 32)",
  "provider_keys": { "gemini": "AIza...", "deepseek": "sk-..." }
}
EOF
sudo chmod 600 /etc/nenya/secrets.json

# Or directory with multiple files (auto-merged)
sudo mkdir -p /etc/nenya/secrets.d
sudo tee /etc/nenya/secrets.d/01-client.json << 'EOF'
{"client_token": "nk-$(openssl rand -hex 32)"}
EOF
sudo tee /etc/nenya/secrets.d/02-providers.json << 'EOF'
{"provider_keys": {"gemini": "AIza...", "deepseek": "sk-..."}}
EOF
sudo chmod 600 /etc/nenya/secrets.d/*.json

See Secrets for full documentation.

Hot Reload

systemctl reload nenya
  • Reloads config from the same path used at startup
  • Re-discovers model catalogs from all configured providers
  • Validates config structure (patterns, enums) but does not ping providers
  • Preserves UsageTracker, Metrics, and ThoughtSignatureCache across reloads
  • On validation failure: logs error, continues serving with old config
  • In-flight requests complete with the gateway they started with

Logging

journalctl -u nenya -f

Enable debug logging:

# In config
# "server": { "log_level": "debug" }

# Or via -verbose flag in systemd unit
# ExecStart=/usr/bin/nenya -verbose

Verify

# Check service status
systemctl status nenya

# Test health endpoint
curl http://localhost:8080/healthz

# Test chat request
curl -H "Authorization: Bearer $(jq -r '.client_token' /etc/nenya/secrets.json)" \
  -H "Content-Type: application/json" \
  -d '{"model":"gemini-3-flash","messages":[{"role":"user","content":"Hello!"}]}' \
  http://localhost:8080/v1/chat/completions

Container (Podman/Docker Compose)

Image

Container image is available on GitHub Container Registry with multi-arch support (amd64/arm64):

ghcr.io/gumieri/nenya:latest

Quick Start

Create minimal config and secrets:

mkdir -p config secrets

cat > config/config.json << 'EOF'
{
  "server": { "listen_addr": ":8080" },
  "agents": {
    "default": {
      "strategy": "fallback",
      "models": ["gemini-3-flash"]
    }
  }
}
EOF

cat > secrets/client.json << 'EOF'
{"client_token": "nk-$(openssl rand -hex 32)"}
EOF

cat > secrets/provider_keys.json << 'EOF'
{"provider_keys": {"gemini": "AIza..."}}
EOF

Podman

podman run -d \
  --name nenya \
  -p 8080:8080 \
  -v ./config:/etc/nenya:ro \
  -v ./secrets:/run/secrets/nenya:ro \
  -e NENYA_SECRETS_DIR=/run/secrets/nenya \
  --cap-drop=ALL \
  --cap-add=IPC_LOCK \
  --security-opt=no-new-privileges:true \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64M \
  ghcr.io/gumieri/nenya:latest

Docker Compose

Use the provided deploy/compose.yml:

mkdir -p config secrets
podman compose -f deploy/compose.yml up -d

Or with Docker:

docker compose -f deploy/compose.yml up -d
services:
  nenya:
    image: ghcr.io/gumieri/nenya:latest
    container_name: nenya
    ports:
      - "8080:8080"
    volumes:
      - ./config:/etc/nenya:ro           # read-only config
      - ./secrets:/run/secrets/nenya:ro  # read-only secrets
    environment:
      NENYA_SECRETS_DIR: /run/secrets/nenya
    cap_drop:
      - ALL
    cap_add:
      - IPC_LOCK                          # required for secure memory (mlock)
    security_opt:
      - no-new-privileges:true            # prevent privilege escalation
    read_only: true                       # immutable root filesystem
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64M    # tmpfs for /tmp
    restart: unless-stopped

Security Notes

Option Purpose
--cap-drop=ALL --cap-add=IPC_LOCK Required for secure memory (mlock)
--security-opt=no-new-privileges:true Prevents privilege escalation
--read-only Enforces immutable root filesystem
--tmpfs /tmp tmpfs for /tmp to prevent writes
Non-root user (UID 65532) Container runs as non-root

Important: IPC_LOCK capability is required for secure memory storage. Without it, Nenya will fail to start with ErrMLockFailure.

Environment Variables

Variable Description
NENYA_CONFIG_DIR Config root directory (default: /etc/nenya/)
NENYA_CONFIG_FILE Single config file (overrides NENYA_CONFIG_DIR)
NENYA_SECRETS_DIR Secrets directory for merged *.json files

Image Verification

Verify the signed image with cosign:

# Verify image signature
cosign verify ghcr.io/gumieri/nenya:latest

# Verify SBOM attestation
cosign verify-attestation --type spdx ghcr.io/gumieri/nenya:latest

Image Updates

podman pull ghcr.io/gumieri/nenya:latest
podman stop nenya && podman rm nenya
# Then re-run the podman run command above

Or with compose:

podman compose pull
podman compose up -d --force-recreate

Kubernetes (Helm)

The Helm chart provides the following components:

Component Description
Deployment Security-hardened with non-root user, read-only fs, tmpfs
Service ClusterIP (default)
ConfigMap Optional: inline config files
Secret Optional: inline secrets (stringData)
Ingress Optional: ingress with TLS

Key security features: non-root user (UID 65532), read-only root filesystem, dropped capabilities (ALL) with only IPC_LOCK added, tmpfs at /tmp.

Prerequisites

  • Kubernetes 1.19+
  • Helm 3.8+
  • kubectl configured with cluster access

Create Namespace

kubectl create namespace nenya

Create Secrets

# From individual files
kubectl create secret generic nenya-secrets \
  --from-file=provider_keys.json=secrets/provider_keys.json \
  --from-file=client.json=secrets/client.json \
  -n nenya

# From literal values
kubectl create secret generic nenya-secrets \
  --from-literal=provider_keys.json='{"provider_keys":{"gemini":"AIza..."}}' \
  --from-literal=client.json='{"client_token":"nk-..."}' \
  -n nenya

Create ConfigMap

# From config directory
kubectl create configmap nenya-config \
  --from-file=config.json=config/config.json \
  -n nenya

# From literal
kubectl create configmap nenya-config \
  --from-literal=config.json='{"server":{"listen_addr":":8080"},"agents":{"default":{"strategy":"fallback","models":["gemini-3-flash"]}}}' \
  -n nenya

Install with Helm

# View all configurable options
helm show values ./deploy/chart/nenya

# Install
helm install nenya ./deploy/chart/nenya \
  --namespace nenya \
  --set secrets.existingSecret=nenya-secrets \
  --set config.existingConfigMap=nenya-config

Configuration Examples

Basic:

# values.yaml
replicaCount: 1
image:
  repository: ghcr.io/gumieri/nenya
  pullPolicy: IfNotPresent
  tag: "latest"
secrets:
  existingSecret: nenya-secrets
config:
  existingConfigMap: nenya-config
service:
  type: ClusterIP
  port: 8080

High availability:

# values.yaml
replicaCount: 3
service:
  type: LoadBalancer
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: ScheduleAnyway
    labelSelector:
      matchLabels:
        app.kubernetes.io/name: nenya

With ingress:

# values.yaml
ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: nenya.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: nenya-tls
      hosts:
        - nenya.example.com

Install with custom values:

helm install nenya ./deploy/chart/nenya \
  --namespace nenya \
  --values values.yaml \
  --set secrets.existingSecret=nenya-secrets \
  --set config.existingConfigMap=nenya-config

Raw Manifests (Non-Helm)

Generate manifests without Helm:

helm template nenya ./deploy/chart/nenya \
  --namespace nenya \
  --set secrets.existingSecret=nenya-secrets \
  --set config.existingConfigMap=nenya-config \
  > nenya-manifests.yaml

kubectl apply -f nenya-manifests.yaml

Verify

kubectl get pods -n nenya
kubectl get svc -n nenya

# Port forward to test
kubectl port-forward -n nenya svc/nenya 8080:8080
curl http://localhost:8080/healthz

Upgrades

helm upgrade nenya ./deploy/chart/nenya \
  --namespace nenya \
  --set secrets.existingSecret=nenya-secrets \
  --set config.existingConfigMap=nenya-config

Uninstall

helm uninstall nenya -n nenya

macOS (launchd)

Installation

git clone https://github.com/gumieri/nenya.git
cd nenya
go build -o nenya ./cmd/nenya/
sudo cp nenya /usr/local/bin/

launchd Service

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.github.gumieri.nenya</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/nenya</string>
        <string>-config</string>
        <string>/usr/local/etc/nenya</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>ThrottleInterval</key>
    <integer>5</integer>
    <key>WorkingDirectory</key>
    <string>/usr/local/etc/nenya</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>CREDENTIALS_DIRECTORY</key>
        <string>/usr/local/etc/nenya/secrets.d</string>
    </dict>
    <key>SoftResourceLimits</key>
    <dict>
        <key>Memory</key>
        <integer>1073741824</integer>
    </dict>
</dict>
</plist>

Install and load:

sudo cp com.github.gumieri.nenya.plist /Library/LaunchDaemons/
sudo chown root:wheel /Library/LaunchDaemons/com.github.gumieri.nenya.plist
sudo launchctl load /Library/LaunchDaemons/com.github.gumieri.nenya.plist

Unload/stop:

sudo launchctl unload /Library/LaunchDaemons/com.github.gumieri.nenya.plist

macOS Secret Hardening

macOS does not support systemd credentials. Use a secrets directory with strict permissions or the macOS Keychain:

  • File-based: chmod 600 on secrets files under CREDENTIALS_DIRECTORY (see Secrets#macOS)
  • Keychain: Use security add-generic-password / security find-generic-password for encrypted at-rest storage

Configuration Directory Layout

/usr/local/etc/nenya/
├── 00-server.json          # server, context, governance, bouncer, compaction
├── 10-providers.json       # provider URL or auth overrides
├── 20-agents.json          # agent definitions
└── secrets.d/              # loaded via CREDENTIALS_DIRECTORY
    ├── 01-client.json
    └── 02-providers.json

Security Hardening (All Deployments)

  • Non-root: Runs as UID 65532 with dropped capabilities (Linux containers/systemd)
  • mlock: IPC_LOCK prevents secrets from swapping to disk (requires LimitMEMLOCK=infinity on Linux; not available on macOS)
  • Read-only root: Immutable filesystem; private /tmp
  • Seccomp: Restricted syscalls via seccomp=unconfined (custom profile recommended; Linux only)
  • No new privileges: NoNewPrivileges=true prevents privilege escalation (Linux systemd/containers)
  • macOS hardening: Use strict file permissions (chmod 600) or Keychain for secrets (see Secrets#macOS)

Troubleshooting

Service fails to start

Check logs:

# systemd
journalctl -u nenya -n 50 --no-pager

# Container
podman logs nenya

# Kubernetes
kubectl describe pod -n nenya -l app.kubernetes.io/name=nenya

Common issues:

  • ErrMLockFailure: Missing IPC_LOCK / LimitMEMLOCK=infinity
  • Config validation error: Check JSON syntax in config files
  • Port already in use: Another service using port 8080
  • Image pull issues: Check registry access and pull policy

Health check failing

curl http://localhost:8080/healthz

Expected response: {"status":"ok","engine":"ollama"} (or similar)

See Also

Getting Started

Core Concepts

Reference

Operations

  • Demo — Test all pipeline tiers
  • Troubleshooting — Common issues and solutions
  • FAQ — Frequently asked questions
  • Security — Security policy and vulnerability reporting

Project

Clone this wiki locally