A placement oracle and ledger for Proxmox infrastructure.
Waggle manages VM placement decisions and maintains an authoritative ledger of node pool configurations across Proxmox hypervisors. Named after the "waggle dance" bees use to communicate directions and timing to the rest of the hive.
Note: Waggle does not create or provision VMs itself. It serves as the source of truth for placement state. VM provisioning is handled by companion systems like River-based workers or your Terraform provider.
- Declarative Node Pool Management — Define desired VM counts per pool; Waggle tracks actual placements
- Multi-Tenant Architecture — Isolated organizations with encrypted per-tenant databases
- Placement Oracle — Allocate VMs to hypervisors based on resource availability and constraints
- Encryption at Rest — Two-tier KEK→DEK envelope encryption for sensitive data
- Job Queue System — River-based background workers for tenant provisioning/destruction
- OpenAPI-Driven SDKs — Automatically generated TypeScript, Go, and REST clients
- Audit Trail — Complete auth event logging and session tracking
- Database Versioning — Schema management via Goose migrations for control and tenant databases
| Component | Technology |
|---|---|
| Language | Go 1.26.3 |
| API Framework | Huma v2 |
| Routing | Chi v5 |
| Database | PostgreSQL |
| ORM | GORM |
| Job Queue | River |
| Migrations | Goose v3 + Atlas |
| Auth | JWT (golang-jwt) |
| Encryption | AES-GCM (crypto/cipher) |
| CLI | Cobra |
- Go 1.26.3 or later
- PostgreSQL 13+
- Docker & Docker Compose (for local development)
- Node.js (for SDK generation)
git clone https://github.com/glueops/waggle.git
cd waggle
cp .env.example .env # Configure as neededjust up # Runs docker-compose up + migrationsgo run . serveServer starts on http://localhost:8080 with API docs at /api/docs.
In a separate terminal:
go run . workerWorkers process River jobs (tenant provisioning, destruction, etc.).
go build -o waggle ./cmd
./waggle servewaggle serve # Start API server
waggle worker # Run background job workers
waggle migrate [args] # Manage database migrations
waggle encrypt # Key management (generate-master, rotate-master)
waggle generate # Code generation (migrations, SDKs)
waggle version # Print version infojust generate <name> # Create new DB migration
just sdk # Generate OpenAPI spec + SDKs
just migrate up # Run pending migrations
just reset # Drop & recreate database
just release # Build release artifactsIn-depth reference docs live in docs/:
- Architecture — planes, request flow, jobs, encryption, placement
- Database — control & tenant schema reference
- API — endpoint and auth reference
- Configuration — every environment variable
- Development — setup, code generation, migrations, contributing
Control Database (shared, encrypted at rest)
- Organizations & Tenants
- Users, Accounts, Emails
- Token Sessions & Refresh Tokens
- Auth Audit Events
- Policies & Passpkeys
Tenant Databases (per-organization, encrypted connection string)
- Datacenters
- Hypervisors (CPU/RAM/Disk capacity tracking)
- Node Pools (declared desired count)
- VM Placements (actual VMID assignments)
- Slots (VM size definitions: vCPU, RAM, Disk)
Waggle uses envelope encryption for sensitive data:
Master Key (KEK) 32 bytes
↓
Tenant Key (DEK) — encrypted with Master Key
↓
Field-Level Encryption — encrypted with Tenant Key
Intentionally Plaintext: Organization.ConnectionString is stored unencrypted in the control DB for operational simplicity.
Asynchronous work is handled by River:
TenantProvisionerWorker— Creates tenant databasesTenantDestroyerWorker— Destroys tenant databases
Scale workers independently from the API server.
- OpenAPI Spec:
GET /api/openapi.json - Interactive Docs:
GET /api/docs - Base Path:
/api/v1
POST /auth/register — Create account
POST /auth/login — Generate JWT
POST /auth/refresh — Refresh access token
POST /auth/logout — Revoke session
GET /organizations — List orgs (paginated)
GET /organizations/{id} — Get org details
POST /organizations — Create org
GET /datacenters/{id} — Get datacenter
GET /hypervisors/{id} — Get hypervisor state
GET /pools/{id} — Get pool placements
POST /placements — Create/update placement
See /docs for the complete interactive specification.
# Server
BIND_HOST=0.0.0.0
BIND_PORT=8080
BASE_PATH=/api/v1
FRONTEND_MODE=embed # none|embed|external
# Database
DATABASE_URL=postgres://user:pass@localhost:5432/waggle?sslmode=disable
ADMIN_DATABASE_URL= # Optional: superuser DSN for tenant creation
# Encryption
ENCRYPTION_MASTER_KEY=<base64-32-bytes> # Generate via: go run . encrypt generate-master
# JWT
JWT_SECRET=<random-secret>
JWT_ISSUER=waggle
JWT_ACCESS_TTL_MIN=60
JWT_REFRESH_TTL_HOUR=720
JWT_AUDIENCE=waggle-apiWaggle uses up to two PostgreSQL roles:
- App role — the credentials in
DATABASE_URL. Runs migrations, serves the API/worker, and owns the tenant databases and the objects in them. - Admin role — the credentials in
ADMIN_DATABASE_URL. Creates and drops the per-tenant databases (tenant_<org-id>). If unset, Waggle derives it fromDATABASE_URLagainst thepostgresmaintenance DB — i.e. the app role does it, and then the app role itself needsCREATEDB.
The PostgreSQL 15+ gotcha:
GRANT ALL PRIVILEGES ON DATABASEdoes not grant privileges on thepublicschema, and PG15+ no longer gives non-ownersCREATEonpublic. So the app role must own each database — owning a DB makes it an implicit member ofpg_database_owner, which ownspublic. Otherwise migrations fail withpermission denied for schema public.
Run the following as a superuser (or your managed provider's master user):
-- 1) App role + control database, owned by the app role.
CREATE ROLE waggle LOGIN PASSWORD 'changeme';
CREATE DATABASE waggle OWNER waggle;
-- 2) Allow the app role to create tenant databases.
-- Skip this if ADMIN_DATABASE_URL points at a separate privileged role.
ALTER ROLE waggle CREATEDB;When the admin role differs from the app role (recommended — see managed
Postgres below), the provisioner sets each new tenant DB's owner to the app
role, so the admin role must be able to SET ROLE to it:
GRANT waggle TO <admin_role>; -- admin_role must be a member of the app roleManaged Postgres (AWS RDS, Cloud SQL, …): there is usually no superuser you
can use, but the provider's master user has the needed privileges. Keep the
least-privileged app role in DATABASE_URL, point ADMIN_DATABASE_URL at the
master user, and run the GRANT waggle TO <master> above so ownership transfer
works:
DATABASE_URL=postgres://waggle:...@host:5432/waggle?sslmode=require
ADMIN_DATABASE_URL=postgres://master:...@host:5432/postgres?sslmode=requirewaggle migrate up runs a preflight check and prints exact remediation SQL if
the role still can't create in public.
go run . encrypt generate-master
# Output: ENCRYPTION_MASTER_KEY=<base64>go run . encrypt rotate-master
# Safely re-wraps all tenant keys with the new master keygo run . migrate up # Apply all pending
go run . migrate status # Show applied migrations
go run . migrate down # Rollback one step
go run . migrate down-to 001 # Rollback to specific versionControl DB and tenant DBs are migrated separately via the --scope flag.
docker build -t waggle:latest .
docker run -d \
-e DATABASE_URL="postgres://..." \
-e ENCRYPTION_MASTER_KEY="..." \
waggle:latest serveWaggle uses code generation heavily:
-
Database Migrations — Generate via Atlas diff
just generate add_user_field
-
OpenAPI + SDKs — Auto-generate from Huma router
just sdk # Generates: # - docs/openapi.json # - ui/src/sdk/ (Hey API, embedded in UI) # - sdk/ts/ (npm package) # - sdk/go/ (Go module)
-
Security — Generated Go SDK patches request/response logging to exclude sensitive headers/bodies
- Modify GORM models in
internal/models/ - Generate migration:
just generate my_change - Review SQL in
internal/migrations/ - Run migration:
just migrate up - Modify API handlers in
internal/handlers/ - Regenerate SDK if API changes:
just sdk - Commit generated files (migrations, SDK)
- Go: Follow
go fmt, no comments unless the "why" is non-obvious - Models: Use GORM tags for schema; JSON tags for API responses
- Error Handling: Return early, wrap with context via
fmt.Errorf - Secrets: Never log auth headers, tokens, or connection strings — patch generated SDKs
go test ./...Integration tests assume a local Postgres instance (provided by docker-compose).
# Test control DB connection
DATABASE_URL=postgres://... go run . migrate status
# Test specific tenant
go run . migrate --scope tenants --org <org-id> statusIf you rotate the master key, all River jobs and running processes must be restarted after the new key is deployed.
Ensure Node.js and npx are available. Generated SDKs require npm dependencies:
npm install sdk/ts
npm install sdk/go[Add your license]
For issues, please open a GitHub issue. For security concerns, email security@glueops.com.
Built with ❤️ by the GlueOps team