agentic-hosting


Real ah --dev loop (not a mock): register a tenant, Nixpacks-build from git, tail logs, service running. Source recording: docs/assets/demo.cast.
A self-hosted PaaS for bare metal servers, designed to be operated by AI agents via REST API. Includes an optional supervisory dashboard for humans.
- Repo: github.com/dennisonbertram/agentic-hosting
- Docs: Documentation site (source in
docs-site/)
- Stack: Go binary + Docker + gVisor + Traefik + Nixpacks + SQLite
- Language: Go 1.25 (requires CGO for SQLite)
- Binary:
ah
- Default port: 8080 (loopback only, behind Traefik)
- License: MIT
Give this to your agent
One shareable link is enough for an agent to install the control plane, register a tenant, deploy from git, add Postgres/Redis, and read logs.
Docs-site copies: /llms.txt and /llms-full.txt (GitHub Pages: https://dennisonbertram.github.io/agentic-hosting/llms.txt). The Claude Code skill and /ah-* slash commands in .claude/ are the same API, split for interactive use. Details: Give this to your agent.
Design Principles
- Agentic-first — AI agents use the REST API directly. The dashboard is optional and supervisory, not the primary operating surface.
- One happy path per primitive — One database engine per type (Postgres, Redis), one build system (Nixpacks), one router (Traefik).
- Multi-tenant — Full tenant isolation via API keys, namespaced resources, per-tenant rate limits.
- Secure by default — gVisor syscall interception, ReadonlyRootfs, dropped capabilities, AES-256-GCM encrypted secrets.
Architecture
Internet
│
Traefik (80/443)
│
┌──────────────┼──────────────┐
│ │ │
ah API Service A Service B
(127.0.0.1:8080) (gVisor) (gVisor)
│
SQLite DBs
/var/lib/ah/
- ah — control plane (single Go binary, systemd service)
- Traefik — reverse proxy and TLS. Service routes are written as file-provider config (
traefik.enable=false on containers so image labels cannot hijack routing)
- gVisor (runsc) — container runtime, syscall interception for tenant isolation
- Nixpacks — zero-config source-to-image builds
- SQLite — two databases:
ah.db (state) and ah-metering.db (metering), WAL mode
Server Requirements
- Linux (Ubuntu 22.04+ recommended)
- Docker 29+ with gVisor runtime configured
- Go 1.25+ with CGO enabled
- Nixpacks installed
- Traefik running as Docker container
AH_BOOTSTRAP_TOKEN environment variable (min 32 chars; authorizes tenant registration)
AH_ADMIN_TOKEN environment variable (min 32 chars per token; authorizes recover, reactivate and quota update)
/var/lib/ah/master.key — hex-encoded 32-byte key
Environment variables read by ah:
| Variable |
Purpose |
AH_BOOTSTRAP_TOKEN |
Tenant registration token (min 32 chars, comma-separated for rotation) |
AH_ADMIN_TOKEN |
Operator token for POST /v1/auth/recover, POST /v1/tenants/{id}/reactivate and PATCH /v1/tenants/{id}/quotas (min 32 chars per token, comma-separated for rotation). If unset, bootstrap tokens are accepted for those routes with a deprecation warning |
AH_METER_DB_PATH |
Metering database path. Defaults to ah-metering.db next to the state database |
AH_BASE_DOMAIN |
Base domain for service URLs (https://{name}.{base-domain}) |
AH_EXTERNAL_TLS_TERMINATED |
true when TLS is terminated in front of Traefik |
Installation
For a Mac mini demo, use the Mac mini first-run guide for the isolated Lima VM, local health check, ngrok setup, and first deployment. It runs the same Go application with Docker and gVisor inside Ubuntu; the existing Hetzner/server install remains separate in the fresh-install guide.
For a fresh Ubuntu 24.04 amd64 host, scripts/bootstrap-stack.sh installs and configures everything below in one run, including generating the master key, bootstrap token and admin token (see the fresh-install guide). The manual steps that follow are for reference or customized setups.
Run the OpenRouter example on the Mac mini
The OpenRouter agent guide records one successful local ARM64 hosted-model request (HTTP 200, calculator result 42) and the manual app/relay setup. It is not a one-command installer; the relay invocation was reconstructed from inspected settings and not replayed verbatim on a fresh VM. The temporary QA VM was deleted, and ngrok, public DNS, and TLS were not tested.
1. Install dependencies
# Docker
curl -fsSL https://get.docker.com | sh
# gVisor
curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list
sudo apt-get update && sudo apt-get install -y runsc
# Configure Docker to use gVisor
cat > /etc/docker/daemon.json <<'EOF'
{
"runtimes": {
"runsc": {
"path": "/usr/bin/runsc"
}
},
"default-address-pools": [{"base":"172.20.0.0/14","size":24}]
}
EOF
systemctl restart docker
# Go
wget -q https://go.dev/dl/go1.25.0.linux-amd64.tar.gz -O /tmp/go.tar.gz
tar -C /usr/local -xzf /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> /etc/profile
# Nixpacks
curl -sSL https://nixpacks.com/install.sh | bash
2. Start infrastructure
# Create Docker network
docker network create --driver bridge traefik-public
# Start Traefik
docker run -d --name paas-traefik --restart unless-stopped \
--network traefik-public \
-p 80:80 -p 443:443 -p 8090:8080 \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /etc/traefik:/etc/traefik \
traefik:latest \
--api.dashboard=true \
--providers.docker=true \
--providers.docker.network=traefik-public \
--providers.file.filename=/etc/traefik/dynamic/ah.yml \
--entrypoints.web.address=:80 \
--entrypoints.websecure.address=:443
# Start local registry
docker run -d --name paas-registry --restart unless-stopped \
-p 127.0.0.1:5000:5000 \
-v /var/lib/ah/registry:/var/lib/registry \
registry:2
3. Build and install ah
# Clone repo
git clone https://github.com/dennisonbertram/agentic-hosting /agentic-hosting
cd /agentic-hosting
# Build (CGO required for SQLite)
CGO_ENABLED=1 go build -o bin/ah ./cmd/ah
cp bin/ah /usr/local/bin/ah
# Create data directory
mkdir -p /var/lib/ah/builds /var/lib/ah/backups
# Generate master key (hex-encoded 32 bytes)
head -c 32 /dev/urandom | xxd -p -c 64 > /var/lib/ah/master.key
chmod 600 /var/lib/ah/master.key
# Generate the bootstrap token and the admin token (run twice; use a different value for each)
openssl rand -hex 32
openssl rand -hex 32
# Set bootstrap and admin tokens
cat > /etc/default/ah <<'EOF'
AH_BOOTSTRAP_TOKEN=<your-32+-char-token-here>
AH_ADMIN_TOKEN=<a-different-32+-char-token-here>
EOF
chmod 600 /etc/default/ah
# Install service
cp deploy/ah.service /etc/systemd/system/ah.service
systemctl daemon-reload
systemctl enable ah
systemctl start ah
# Verify from the host (the API only trusts forwarded HTTPS headers from loopback)
curl -H 'X-Forwarded-Proto: https' http://127.0.0.1:8080/v1/system/health
# → {"status":"ok"}
API Reference
Authentication
Tenant routes require a Bearer key. Public (no API key): GET /v1/system/health, POST /v1/tenants/register, POST /v1/auth/recover, POST /v1/tenants/{tenantID}/reactivate, POST /v1/system/bootstrap-token/validate, PATCH /v1/tenants/{tenantID}/quotas. Register sends X-Bootstrap-Token. Recover, reactivate, and quota patch send X-Admin-Token when AH_ADMIN_TOKEN is set (bootstrap tokens then get 403 on those routes); if unset, X-Bootstrap-Token still works (deprecated). Bootstrap validate takes {"token":"…"} in the JSON body and always returns 200 {"valid":true|false}.
Authorization: Bearer <api_key>
API key format: keyID.secret (returned on registration and key creation).
Registration
# Register a tenant (requires bootstrap token)
curl -X POST http://localhost:8080/v1/tenants/register \
-H 'Content-Type: application/json' \
-H 'X-Bootstrap-Token: <bootstrap-token>' \
-d '{"name": "my-tenant", "email": "admin@example.com"}'
# Response
{
"tenant_id": "ten_abc123",
"api_key": "keyid.secret"
}
Key recovery
Recovery needs the admin token, not the bootstrap token. The response contains a new API key in the key field, shown once:
curl -X POST http://localhost:8080/v1/auth/recover \
-H 'Content-Type: application/json' \
-H 'X-Admin-Token: <admin-token>' \
-d '{"email": "admin@example.com"}'
# Response
{
"id": "<key-id>",
"key": "keyid.secret",
"name": "...",
"created_at": <unix-seconds>
}
Health
GET /v1/system/health # Public — {"status":"ok"}
GET /v1/system/health/detailed # Auth required — disk, docker, gVisor status
Tenant Management
GET /v1/tenant # Get tenant info
PATCH /v1/tenant # Update name: {"name": "new-name"}
DELETE /v1/tenant # Soft-delete (suspends tenant, stops all services)
API Keys
POST /v1/auth/keys # Create key: {"name":"ci-key","expires_in":2592000}
GET /v1/auth/keys # List keys (secrets not shown)
DELETE /v1/auth/keys/{keyID} # Revoke key
Services
Services are Docker containers run with gVisor isolation.
Images may come from Docker Hub or the local loopback registry on 127.0.0.1:5000 / localhost:5000.
# Create and deploy a service
POST /v1/services
{
"name": "my-app",
"image": "127.0.0.1:5000/tenant/my-app:latest",
"port": 3000,
"env": {"NODE_ENV": "production"}
}
# Returns immediately with status "deploying"
GET /v1/services # List services
GET /v1/services/{serviceID} # Get service (includes status, URL)
GET /v1/services/{serviceID}/logs?follow=true&tail=100 # Stream runtime logs
DELETE /v1/services/{serviceID} # Delete service and container
POST /v1/services/{serviceID}/start # Start
POST /v1/services/{serviceID}/stop # Stop
POST /v1/services/{serviceID}/restart # Restart
POST /v1/services/{serviceID}/reset # Reset circuit breaker
GET /v1/services/{serviceID}/env # List env vars (masked by default)
GET /v1/services/{serviceID}/env?reveal=true # Show values
POST /v1/services/{serviceID}/env # Set vars: {"KEY": "value"}
DELETE /v1/services/{serviceID}/env/{key} # Delete one var
Builds (Nixpacks)
Build from a git repository. Supports GitHub, GitLab, Bitbucket, sr.ht, Codeberg (HTTPS only).
# Start a build
POST /v1/services/{serviceID}/builds
{
"source_type": "git",
"source_url": "https://github.com/org/repo",
"source_ref": "main"
}
# source_ref defaults to "main" if omitted
# Returns: {"build_id": "...", "status": "pending", "image": "127.0.0.1:5000/..."}
GET /v1/services/{serviceID}/builds # List builds
GET /v1/services/{serviceID}/builds/{buildID} # Get build status
GET /v1/services/{serviceID}/builds/{buildID}/logs?follow=true # Stream logs
DELETE /v1/services/{serviceID}/builds/{buildID} # Cancel build
Databases
Managed Postgres and Redis containers with encrypted connection strings.
# Create database (takes ~10-30s)
POST /v1/databases
{
"name": "my-db",
"type": "postgres" # or "redis"
}
GET /v1/databases # List databases
GET /v1/databases/{dbID} # Get database
GET /v1/databases/{dbID}/connection-string # Get connection string (decrypted)
DELETE /v1/databases/{dbID} # Delete database and data (not limited to 30s; stops the container, wipes and removes the volume)
Operational Details
File Layout
/var/lib/ah/
├── ah.db # State database (tenants, services, builds, databases)
├── ah-metering.db # Metering database
├── master.key # AES-256 master key (hex-encoded, chmod 600)
├── builds/ # Nixpacks build workdirs (GC after build)
├── backups/ # SQLite backups
└── registry/ # Docker registry data
Background Processes
- Reconciler (30s interval): Syncs DB state to Docker reality. Detects crashes, circuit breakers.
- GC (5min interval): Cleans orphaned containers, volumes, images, build directories.
- Circuit breaker: 5 crashes in 10 minutes stops restarting. Reset with
POST /v1/services/{id}/reset.
- Disk watermarks: Warns at 80% usage, blocks new deploys at 90%.
Backups
ah backup
# Creates timestamped gzip backups in /var/lib/ah/backups/
# Keeps the newest 10 state and 10 metering backups
# Uses VACUUM INTO for WAL-safe snapshots
Security Model
- gVisor: All tenant containers run with
--runtime=runsc (syscall interception)
- ReadonlyRootfs: Container root filesystem is read-only; tmpfs mounts for
/tmp, /var/run, /var/tmp, /run
- CapDrop=ALL: No Linux capabilities
- no-new-privileges: Containers cannot escalate privileges
- Encrypted secrets: Env vars and database credentials encrypted with AES-256-GCM using tenant-derived keys
- API keys: HMAC-SHA256 of
keyID.secret using the master key (constant-time compare). bcrypt is used for passwords, not API keys.
- Rate limiting: Per-tenant (100 req/s burst 200) + global (500 req/s burst 1000)
- Idempotency: Authenticated mutating routes accept
Idempotency-Key (tenant + method + path + body hash). TTL is 10 minutes. POST /v1/environments/{id}/exec is excluded.
Networking
- Without
--base-domain: http://{serviceID}.localhost
- With
--base-domain example.com: https://{dns_label}.example.com
- Traefik file-provider config is written by
ah; containers set traefik.enable=false
- ah API is loopback-only by default (Traefik reverse proxies it)
Development
# Build
make build
# Run locally (requires Docker + gVisor)
AH_BOOTSTRAP_TOKEN=dev-token-32-chars-minimum AH_ADMIN_TOKEN=dev-admin-token-32-chars-minimum make run
# Run in dev mode (no HTTPS enforcement, open registration)
./bin/ah --dev --open-registration --port 8080
# Backup databases
./bin/ah backup
Claude Code Integration
agentic-hosting ships with a Claude Code skill, slash commands, and bash scripts for operating the API without writing curl commands manually.
Install the Skill
Copy the skill to your Claude Code skills directory:
curl -fsSL https://agentic.hosting/install-skill.sh | bash
# or copy from the repo:
mkdir -p ~/.claude/skills/agentic-hosting
cp .claude/skills/agentic-hosting/SKILL.md ~/.claude/skills/agentic-hosting/SKILL.md
cp .claude/commands/ah-*.md ~/.claude/commands/
Claude will automatically load the skill and know how to operate agentic-hosting: authentication, deployment flows, database provisioning, error handling, and all API limits.
Slash Commands
Files in .claude/commands/ are named ah-*.md. There are no /deploy /status /db /logs command files.
| Command |
Description |
/ah-deploy <git-url-or-image> <name> [port] |
Deploy from a git URL (Nixpacks build) or Docker image |
/ah-status |
Full dashboard — disk, services, databases, circuit breakers |
/ah-db <service> <postgres|redis> [name] |
Provision a database and wire it to a service |
/ah-logs <service> [build-id] |
Stream runtime logs, or build logs when a build-id is given |
/ah-register <name> <email> |
Register a tenant (needs bootstrap token) |
/ah-snapshot <service> [name] |
Snapshot a service for forking |
Example usage in Claude Code:
/ah-deploy https://github.com/org/my-app web 3000
/ah-status
/ah-db web postgres
/ah-logs web
Bash Scripts
Standalone scripts that work without Claude:
# Set credentials
export AH_URL="https://<your-domain>"
export AH_KEY="keyid.secret"
# Register a new tenant (one-time)
AH_BOOTSTRAP_TOKEN=<token> ./scripts/register.sh my-tenant me@example.com
For deploys, status, build logs and databases, use the ahc CLI:
ahc deploy https://github.com/org/repo my-app
ahc deploy nginx:alpine my-site
ahc status
ahc db provision my-app postgres
ahc build logs my-app
See scripts/README.md for full documentation.
Comparisons
Documentation site
The public docs match the agentic.hosting landing page (Instrument Serif + Inter, stone palette) and are generated from docs-site/ (Astro). They describe only what the Go API actually implements — see internal/api/server.go. Stale-vs-correct claims from the alignment pass are listed in docs-site/CORRECTIONS.md.
cd docs-site
npm ci
npm run dev # http://127.0.0.1:4321/
npm run build # GitHub Pages defaults (base /agentic-hosting/)
npm run build:site # for the website container (base /docs/)
GitHub Pages deploys on push to main via .github/workflows/docs.yml. After enabling Pages (source: GitHub Actions), the site is https://dennisonbertram.github.io/agentic-hosting/.
To bake docs into the nginx website image, build from the repository root:
docker build -f website/Dockerfile -t website .
Planned Work
See GitHub Issues and the docs roadmap. Known gaps include HKDF key separation, billing (metering is query-only), Prometheus export, and multi-node SQLite. Audit log lines already exist for sensitive reads.
License
This project is licensed under the MIT License. Copyright © 2026 Dennison Bertram.