Skip to content

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 gcloud on 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.com resolves to the VM's tailnet address
HTTPS on the tailnet

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 only

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
Deploys the VM

GitHub Actions

  • Terraform plan on pull requests, apply on main, through Workload Identity Federation
  • Builds the images and pushes them to Artifact Registry
People on the tailnet reach Caddy on the VM. Caddy checks who they are and proxies to Humagen on loopback. The VM has no public address: everything it calls goes out through Cloud NAT.

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.

compose.yaml
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/tailscale

The 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.

config/humagen.yaml
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.

Caddyfile
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:

config/policy.authz
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.

Shell
# 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 -f

The 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.

Setups compared: when to use each and what you get
SetupWhenWhat you get
humagen startTry it on your laptopOne 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 OIDCOne host, your identity providerHumagen and Postgres 17, with optional MinIO for files. Sign-in in a container is OIDC. Docker Compose guide
Helm on KubernetesA cluster you already runOne replica, non-root, read-only root filesystem. Bring your own Postgres. Flux can deploy it from Git. Helm chart guide
humagen deployLet the spec chooseThe 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: unisolated runs file tools and stdio MCP servers in Humagen's own container and refuses the shell. The docker driver 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.