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
| You have | Run | Runbook |
|---|---|---|
| A fresh Ubuntu 24.04 server (amd64/arm64) | sudo scripts/setup-linux.sh --domain example.com --acme-email ops@example.com |
setup-linux |
| An existing Linux install to upgrade or repair | sudo scripts/setup-linux.sh |
operations |
| A Mac for local development | scripts/setup-macos.sh then make sandbox |
setup-macos |
| Any machine with Docker, for a throwaway instance | make sandbox / make sandbox-down |
local-sandbox |
Every setup script accepts --dry-run (check prerequisites, print the plan, change nothing) and --help. The macOS setup and the sandbox use the runc runtime when gVisor is unavailable; that is a dev-only fallback with no syscall sandbox, ah refuses it without --dev, and it logs a warning banner at startup. Production always runs workloads under gVisor (runsc). Day-2 tasks (upgrade, backup and restore, master key rotation, logs, troubleshooting) are in the operations runbook.
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.
| What | URL (after merge to main) |
|---|---|
| llms.txt (llmstxt.org index) | https://raw.githubusercontent.com/dennisonbertram/agentic-hosting/main/llms.txt |
| llms-full.txt (all docs) | https://raw.githubusercontent.com/dennisonbertram/agentic-hosting/main/llms-full.txt |
| Operator skill | https://raw.githubusercontent.com/dennisonbertram/agentic-hosting/main/skills/agentic-hosting/SKILL.md |
Docs-site copies: /llms.txt and /llms-full.txt (GitHub Pages: https://agentic.hosting/docs/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.
- 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.
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=falseon 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) andah-metering.db(metering), WAL mode
- 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_TOKENenvironment variable (min 32 chars; authorizes tenant registration)AH_ADMIN_TOKENenvironment 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 |
AH_CORS_ORIGINS |
Comma-separated browser Origin allowlist for the dashboard. Default: https://agentic.hosting, https://www.agentic.hosting, https://dennisonbertram.github.io. * is ignored |
AH_CONTAINER_RUNTIME |
OCI runtime for tenant workloads: runsc (default, gVisor) or runc. runc is a dev-only fallback for hosts without gVisor (macOS); ah refuses it unless started with --dev |
AH_WARM_POOL_ENABLED |
false disables the pre-started environment containers (default true). The local sandbox turns it off so it does not pull every template image |
AH_GC_PRUNE_DOCKER |
false stops GC from running daemon-wide dangling-image and unused anonymous-volume prunes (default true). Set it when the Docker daemon is shared with unrelated work |
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.
The quickest path is scripts/setup-linux.sh (see Quick start); on a fresh host it runs scripts/bootstrap-stack.sh and then installs ahc, and on an existing host it upgrades in place. 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.
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.
# 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# 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# 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"}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).
# 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"
}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>
}GET /v1/system/health # Public — {"status":"ok"}
GET /v1/system/health/detailed # Auth required — disk, docker, gVisor statusGET /v1/tenant # Get tenant info
PATCH /v1/tenant # Update name: {"name": "new-name"}
DELETE /v1/tenant # Soft-delete (suspends tenant, stops all services)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 keyServices 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 varBuild 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 buildManaged 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)Connection strings use the DNS alias ah-db-<databaseID> and the engine port (5432 / 6379). That name resolves only inside containers attached to ah-dbnet-<tenant>-<db> (Internal=true, ICC=true). Set DATABASE_URL / REDIS_URL from GET /v1/databases/{id}/connection-string, then restart so the service joins that network. Host-loopback publish remains for control-plane health probes only — do not curl the URL from the host. Other tenants are never attached.
/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
- Reconciler (30s interval): Syncs DB state to Docker reality. Detects crashes, circuit breakers.
- GC (5min interval): Cleans orphaned containers, volumes, images (including public images ah pulled for image-sourced sandboxes once nothing uses them), build directories.
- Circuit breaker: 5 crashes in 10 minutes stops the container and removes it. Reset with
POST /v1/services/{id}/reset, thenPOST .../redeploy(/startreturns 409 because there is no container). - Disk watermarks: Warns at 80% usage, blocks new deploys at 90%. Build, database create, kanban create, sandbox create, deploy and restart/redeploy return 503
{"error":"insufficient disk space; try again later"}withRetry-After: 10; service create returns 201 then fails async. A full deploy queue is also 503 withRetry-After. - Tenant resource defaults: New tenants get 2048 MB / 2 CPUs from
tenant_quotas. Fallback 512 MB / 1 CPU only if the quota row cannot be loaded. Limits are per-tenant and still bounded by the host.
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- 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 the server master key. There is no per-tenant key separation yet.
- API keys: HMAC-SHA256 of
keyID.secretusing the master key (constant-time compare). bcrypt is used for passwords, not API keys. - Rate limiting: Per-tenant (default 100 req/s burst 200, from
tenant_quotas.api_rate_limit) + global (500 req/s burst 1000) - Idempotency:
Idempotency-Keyapplies to POST and PUT only (not DELETE/PATCH; no PUT routes exist). Scoped tenant + method + path + body hash. TTL is 10 minutes.POST /v1/auth/keysandPOST /v1/environments/{id}/execare excluded.
- 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 settraefik.enable=false - ah API is loopback-only by default (Traefik reverse proxies it)
# Build
make build
# Run locally (requires Docker + gVisor)
AH_BOOTSTRAP_TOKEN=dev-bootstrap-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
# Throwaway local instance with a demo tenant and service (macOS or Linux)
make sandbox
make sandbox-down
# Dry-run checks for the setup scripts (no sudo, nothing installed)
make test-scripts
# Backup databases
./bin/ah backupagentic-hosting ships with a Claude Code skill, slash commands, and bash scripts for operating the API without writing curl commands manually.
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.
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
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.comFor deploys, status, build logs and databases, use the ahc CLI:
ahc deploy https://github.com/org/repo my-app
ahc deploy nginxinc/nginx-unprivileged my-site --port 8080
ahc status
ahc db provision my-app postgres
ahc build logs my-appSee scripts/README.md for full documentation.
- ah vs OpenShell / NemoClaw — where each tool fits and how they complement each other
- InstaBox compatibility (2026-10-14) — can ah run on InstaBox (InsForge / Hang Huang)? Likely as a constrained
--devsingle-tenant box; blocked for stock production install and unattended signup
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 + agentic.hosting (base /docs/)GitHub Pages deploys on push to main via .github/workflows/docs.yml. The combined artifact serves the homepage from website/ at https://agentic.hosting/ and the Astro docs at https://agentic.hosting/docs/. Custom domain and DNS steps: docs/website/github-pages.md. The static dashboard at /dashboard/ defaults its API base to https://api.agentic.hosting on that host (?api= overrides). The API reflects Access-Control-Allow-Origin for that allowlist (AH_CORS_ORIGINS / --cors-origins); it never uses * and does not set credentials.
The Hetzner website container still builds from the repository root (used for www.agentic.hosting, which 301s to the GitHub repo):
docker build -f website/Dockerfile -t website .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.
This project is licensed under the MIT License. Copyright © 2026 Dennison Bertram.
