Deploy your own Humagen
One private VM per team on Google Cloud, reachable only over Tailscale. Docker Compose runs Humagen, Postgres and Caddy. Gemini answers through Vertex AI with the VM's service account, so there is no model key to keep. Terraform describes all of it and GitHub Actions applies it.
This is the setup Humagen's author runs in production. The steps show the parts that matter; adapt names, regions and sizes to your project.
Prerequisites
- A Google Cloud project with billing, and the Compute Engine, Vertex AI, Artifact Registry, Secret Manager and Cloud DNS APIs turned on.
- A Tailscale tailnet, and a reusable, tagged auth key stored in Secret Manager. It is added by hand, so it never enters Terraform state.
- A domain, or a subdomain you can delegate to a Cloud DNS zone, for the console's address and its certificate.
- A GitHub repository for the Terraform, with Actions turned on.
- Terraform and
gcloudon your machine, once, to create the state bucket and CI's identity.
An e2-medium (2 vCPU, 4 GB) with 4 GB of swap is enough. In the reference setup the VM and disk cost about $25 a month, plus Vertex AI calls.
Architecture
Your tailnet
- People's laptops and phones
https://humagen.example.comresolves to the VM's tailnet address
One GCE VM, no public IP
- Caddy with caddy-tailscale: certificate by DNS-01, caller identity from tailscaled
- Humagen on
127.0.0.1:8080 - Postgres 17 on
127.0.0.1:5432 humagen-watch: re-runs the startup script when Terraform changes it
Outbound through Cloud NAT
- Vertex AI: Gemini and embeddings, with the VM's service account
- Artifact Registry: the Humagen and Caddy images
- Secret Manager: the Tailscale auth key
- Cloud DNS: the tailnet address and the ACME challenge
GitHub Actions
- Terraform plan on pull requests, apply on main, through Workload Identity Federation
- Builds the images and pushes them to Artifact Registry
Step 1: A private network and VM
Create a VPC whose firewall admits nothing but IAP SSH (35.235.240.0/20 on port 22). Tailscale connects outbound, so it needs no inbound rule. Add a Cloud Router with Cloud NAT so the VM can reach Google APIs, Tailscale and the package mirrors.
Give the VM no access config, so it has no public address, and its own service account with only what it uses: roles/aiplatform.user for Vertex AI, roles/logging.logWriter, roles/artifactregistry.reader on the image repository, access to the Tailscale key's secret, and roles/dns.admin on its own zone.
The startup script installs Docker and Tailscale, joins the tailnet once with the key from Secret Manager, writes the VM's tailnet address into the zone's A record, makes a database password that never leaves the VM, writes the files below and starts the stack. The name resolves for anyone, but only tailnet members can reach the address.
Step 2: Humagen, Postgres and Caddy in Compose
Everything is on the host network and bound to loopback, except Caddy. Humagen must listen on loopback: Tailscale sign-in refuses any other address.
services:
humagen:
image: REGION-docker.pkg.dev/YOUR_PROJECT/humagen/humagen:v0.46.0
network_mode: host
command: serve -f /etc/warp -addr 127.0.0.1:8080 -web /usr/share/warp/web
env_file: .env # HUMAGEN_POSTGRES_URL, made on the VM
volumes:
- humagen-data:/var/lib/warp
- ./config:/etc/warp:ro
depends_on:
postgres: {condition: service_healthy}
postgres:
image: postgres:17-alpine
ports: ["127.0.0.1:5432:5432"]
volumes: [postgres-data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U humagen -d humagen"]
caddy:
image: REGION-docker.pkg.dev/YOUR_PROJECT/humagen/caddy:TAG
network_mode: host
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /var/run/tailscale:/var/run/tailscaleThe image paths keep their original names (/etc/warp, /var/lib/warp) so older volumes and specs keep working.
Step 3: The spec
One file holds two documents. The System document says how Humagen runs: sign-in, stores, files, knowledge, sandbox. The Platform document says what the workspace offers: models and agents. Secrets are references such as env:HUMAGEN_POSTGRES_URL, never values.
api_version: humagen/v1
kind: System
metadata:
name: humagen
spec:
server:
public_url: https://humagen.example.com
auth:
kind: tailscale # Caddy passes the caller's login from loopback
tenant: {fixed: default}
policy_file: /etc/warp/policy.authz
stores:
kind: postgres
postgres: {url: {ref: env:HUMAGEN_POSTGRES_URL}}
bus:
kind: postgres
blobs:
kind: disk
knowledge:
embedder:
kind: vertex
vertex: {project: YOUR_PROJECT, location: us-central1}
sandbox:
kind: unisolated # no Docker socket in the container
execution:
builtin: {provider: gemini, model: gemini-3-flash-preview}
---
api_version: humagen/v1
kind: Platform
metadata:
name: default
spec:
models:
- name: gemini
kind: google
google:
vertex: {project: YOUR_PROJECT, location: global}
agents:
- spec:
name: assistant
provider: gemini
model: gemini-3-flash-preview
system_prompt: You are a helpful assistant. Answer plainly.Run humagen config check -f config/humagen.yaml before you ship a change. It does not resolve references or read the files a spec names, so an unset variable shows only at start.
Step 4: Sign in with Tailscale
With auth.kind: tailscale there is no identity provider and no password. Caddy's tailscale_auth asks the local tailscaled who each caller is and refuses anyone it does not know, including the internet and tagged devices. It replaces the identity headers a caller sent, and Humagen trusts them only from loopback.
humagen.example.com {
tls {
dns googleclouddns {
gcp_project YOUR_PROJECT
}
}
tailscale_auth
reverse_proxy 127.0.0.1:8080 {
header_up -Tailscale-User-Groups
header_up -Tailscale-User-Profile-Pic
header_up Tailscale-User-Login {http.auth.user.tailscale_user}
header_up Tailscale-User-Name {http.auth.user.tailscale_name}
}
}Caddy gets its certificate from Let's Encrypt with a DNS-01 challenge on Cloud DNS, so the VM never needs to be reachable from the internet. Build it with xcaddy and the caddy-tailscale and caddy-dns/googleclouddns modules.
Every tailnet member signs in with no permissions until the policy grants one. Grant roles to Tailscale logins:
role admin { allow * on * }
grant admin to subject "tailscale:you@github"Step 5: Models on Vertex AI
A model of kind: google with a vertex block calls Gemini through Vertex AI with Application Default Credentials: on the VM, its service account. There is no API key to store or rotate, and calls are billed to the project.
Gemini 3 previews are served from the global location. Agents can still name other providers in the same Platform document, with their keys as env: references.
Step 6: Knowledge with hybrid search
knowledge.embedder: vertex gives each passage a Vertex AI embedding (gemini-embedding-001, 768 dimensions by default) in the background, so an upload never waits on a model. Search ranks passages by vector similarity and by BM25 and fuses the two lists.
If the embedder fails, writes still succeed and search falls back to lexical. Search sees exactly the documents it would without vectors: the same sources and access checks.
Step 7: CI applies Terraform and builds the images
GitHub Actions reaches Google Cloud through Workload Identity Federation, with no service account keys. Pull requests run terraform plan as a read-only account and post the plan as a comment. A merge to main runs terraform apply in a protected production environment.
An images job builds each tag missing from the private Artifact Registry: Humagen from urmzd/humagen at the release you pin (about 10 minutes), and the custom Caddy. Releases do not publish an image yet, so this build is required.
On the VM, humagen-watch, a systemd service, waits on the instance's startup-script metadata. When an apply changes it, the service re-runs the script, which writes the new config, retries docker compose pull for up to 30 minutes until the new images exist, and restarts the stack. Upgrading is a one-line change to the pinned release. No SSH needed.
Step 8: Check that it works
/healthz answers ok. Open the console at your domain from a tailnet device: you are signed in as your Tailscale login.
# From a laptop on the tailnet
curl -fsS https://humagen.example.com/healthz # ok
# On the VM, over IAP SSH
gcloud compute ssh humagen --zone ZONE --tunnel-through-iap -- \
'sudo docker compose -f /opt/humagen/compose.yaml ps; curl -fsS http://127.0.0.1:8080/healthz'
# Follow a rollout
gcloud compute ssh humagen --zone ZONE --tunnel-through-iap -- \
sudo journalctl -u humagen-watch -fThe first boot waits for CI's images, about 10 to 15 minutes. Its log is /var/log/humagen-startup.log on the VM.
Other ways to run it
Every setup runs the same process with the same spec.
| Setup | When | What you get |
|---|---|---|
humagen start | Try it on your laptop | One process: loopback sign-in, in-memory stores, seeded example agents on an offline model. No key needed. Quick start in the README |
| Docker Compose with OIDC | One host, your identity provider | Humagen and Postgres 17, with optional MinIO for files. Sign-in in a container is OIDC. Docker Compose guide |
| Helm on Kubernetes | A cluster you already run | One replica, non-root, read-only root filesystem. Bring your own Postgres. Flux can deploy it from Git. Helm chart guide |
humagen deploy | Let the spec choose | The spec's deploy slot picks local, compose or kubernetes. -o DIR writes what it would apply and stops. Deploying it, in the spec reference |
Know before you deploy
What this setup does not do yet, from the status table in the repository.
- One replica. The stores are single-writer, and a second process on the same database is refused at start.
- Sessions are in memory. A restart signs everyone out.
- No published image. Build it from the repository and push it to your registry, as the CI above does.
- Tools in this setup are not isolated.
sandbox.kind: unisolatedruns file tools and stdio MCP servers in Humagen's own container and refuses the shell. Thedockerdriver isolates each run but needs a dedicated sandbox host, and its instances have no network yet. A Kubernetes sandbox driver is not built. - Tailscale sign-in has no Slack app. A process cannot sign in through Tailscale, so the Slack app needs OIDC sign-in. Slack and GitHub are tested against simulated APIs.
Status table in the architecture overview
Stuck, or running it somewhere else? Write to founder@withhumagen.com.