Deploy the Maestro app
Walkthrough for getting Maestro running on your own infrastructure. Total time: ~30 minutes the first time.
The recommended path for v1 self-host is Docker Desktop on Windows 11 Pro behind Cloudflare Tunnel + Cloudflare Access. That’s what this guide covers in depth. The same Docker images run on Linux, macOS, and managed platforms (Fly, Railway, DigitalOcean, etc.) — alternative-target notes are at the bottom.
You’ll set up:
- Docker Desktop — runs the Maestro stack (Postgres + API + runtime).
- Cloudflare Tunnel — gives Maestro a public URL without opening any inbound ports on your network.
- Cloudflare Access — gates the Maestro UI behind email-based SSO. No auth code in Maestro yet; Access handles it at the edge.
- Maestro itself — pulled from GHCR, configured via a
.envfile.
Prerequisites
- Windows 11 Pro (Linux + macOS work too; deviations noted inline).
- Docker Desktop — download. Free for personal use, education, non-commercial open source, and companies with fewer than 250 employees AND less than $10M annual revenue.
- A Cloudflare account with a domain you control. Free plan is fine.
- A subdomain to point at this install (e.g.
app.yourdomain.com). - The Maestro repo cloned somewhere on the box:
git clone https://github.com/mcinnisdev/Maestro.git.
Step 1 — Configure your .env
Maestro reads its config from a .env file at the repo root. Copy the example and fill in the required values:
cp .env.example .env
The three required values for production:
# Generate a fresh master key (different from your dev install's key)
MAESTRO_SECRET_KEY=$(openssl rand -base64 32)
# Anthropic key — same one you use for dev is fine
ANTHROPIC_API_KEY=sk-ant-...
# The public URL where Maestro will be reachable
MAESTRO_PUBLIC_URL=https://app.yourdomain.com
On Windows without
openssl: install Git for Windows (which bundles OpenSSL via Git Bash), or run[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))in PowerShell.
Back up
MAESTRO_SECRET_KEYseparately. If you lose it, every secret encrypted with it (Gmail tokens, Apollo keys, etc.) becomes unrecoverable. Store it in a password manager, not in the same place as your DB backups.
Optional but recommended:
# Pick a strong DB password instead of the literal "maestro"
POSTGRES_PASSWORD=<a-strong-random-string>
# Pin a specific Maestro release rather than tracking `latest`
MAESTRO_VERSION=0.1.0
Step 2 — Bring up the Maestro stack
From the repo root:
docker compose -f infra/docker/docker-compose.prod.yml --env-file .env up -d
This pulls three images from GHCR (ghcr.io/mcinnisdev/maestro-api, maestro-runtime, plus official postgres:17-alpine) and starts them. First-time pull takes 1–2 minutes; subsequent updates are smaller layer diffs.
The API container automatically:
- Waits for Postgres to become healthy.
- Runs database migrations.
- Bootstraps the workspace + the two hero-score agents (
cold-leads,reply-triage) — both asidleso nothing fires until you flip them on. - Starts the API server on port 3001 (bound to
127.0.0.1only — not exposed to your network).
Verify the stack is up:
docker compose -f infra/docker/docker-compose.prod.yml ps
curl http://127.0.0.1:3001/health
# → {"ok":true}
Step 3 — Install Cloudflare Tunnel
Cloudflare Tunnel (cloudflared) connects your box to Cloudflare’s edge over an outbound-only tunnel. No port forwarding on your router, no static IP, no DDNS.
On Windows:
- Download the latest
cloudflared.exefrom github.com/cloudflare/cloudflared/releases (pickcloudflared-windows-amd64.exe). - Move it to
C:\Program Files\cloudflared\cloudflared.exe. - Open PowerShell as Administrator.
On Linux / macOS: install via your package manager (brew install cloudflared on macOS, apt install cloudflared on Debian/Ubuntu via the Cloudflare repo).
3a — Authenticate
cloudflared tunnel login
This opens your browser to authorize the tunnel against your Cloudflare account. After authorizing, a cert lands in C:\Users\<you>\.cloudflared\cert.pem.
3b — Create the tunnel
cloudflared tunnel create maestro-app
Prints a tunnel UUID and writes credentials to C:\Users\<you>\.cloudflared\<UUID>.json. Keep that UUID handy.
3c — Configure the tunnel
Create C:\Users\<you>\.cloudflared\config.yml:
tunnel: <UUID>
credentials-file: C:\Users\<you>\.cloudflared\<UUID>.json
ingress:
- hostname: app.yourdomain.com
service: http://127.0.0.1:3001
- service: http_status:404
The Tunnel routes app.yourdomain.com → localhost:3001 (the API container, which serves both /api/* and the web app). Anything else returns 404.
3d — Wire up DNS
cloudflared tunnel route dns maestro-app app.yourdomain.com
This adds a CNAME record in Cloudflare DNS pointing app.yourdomain.com → <UUID>.cfargotunnel.com. Cloudflare manages TLS automatically.
3e — Run as a service
So the tunnel survives reboots and runs without you logged in:
cloudflared service install
Verify the service is running:
Get-Service cloudflared
# Status: Running
Test the tunnel before adding auth: open https://app.yourdomain.com in a browser. You should see the Maestro UI (no login yet — that’s the next step).
Step 4 — Add Cloudflare Access
Without Access, anyone with the URL can use your Maestro install. Access puts an SSO gate in front. Free for up to 50 users.
- Cloudflare dashboard → Zero Trust → Access → Applications → Add an application → Self-hosted.
- Application configuration:
- Application name:
Maestro - Application domain:
app.yourdomain.com - Identity providers: One-time PIN (default; emails a code) is the simplest. Add Google / Microsoft / GitHub OAuth as identity providers if you want SSO instead.
- Application name:
- Click Next, configure the policy:
- Policy name:
Operators - Action:
Allow - Configure rules → Include → Emails → list the email addresses of operators (you, design partners). Or Emails ending in to allow a whole domain.
- Policy name:
- Save the application.
Now reload https://app.yourdomain.com — you’ll get a Cloudflare Access login screen. Enter your email, get a PIN, paste it. Access stamps a JWT cookie and forwards the request to your tunnel. Maestro’s UI loads.
Step 5 — Update Google Cloud OAuth (for Gmail skill)
The Gmail OAuth callback URL needs to match your production domain. In your Google Cloud project:
- APIs & Services → Credentials → click the OAuth 2.0 client you set up for dev.
- Under Authorized redirect URIs, add:
https://app.yourdomain.com/api/oauth/google/callback. - Keep the localhost URI for dev. Save.
Now in Maestro:
- Sign in via Cloudflare Access.
- Secrets → add
google_oauth_client_idandgoogle_oauth_client_secret(same values you use in dev — or fresh ones if you want them isolated). - Skills → Gmail → click Connect. Complete the Google consent flow on the production domain.
Step 6 — Configure your real cold-leads agent
- Agents → Cold leads · SaaS → Edit.
- Replace the seed sender (
Nick from Maestro) and ICP with your own. - The agent ships in review mode by default — it drafts openers but does not send. The operator reviews each draft on the contact detail page and clicks Send via Gmail when satisfied. Recommended for the first 5–10 runs.
- Add
apollo_api_keyto Secrets (see docs/skills/apollo.md for setup). - Set the agent status to
runningorscheduledwhen you’re ready to go live.
The full score reference is at B2B SaaS Outbound.
Operating the install
View logs:
docker compose -f infra/docker/docker-compose.prod.yml logs -f api runtime
Update Maestro to a new release:
# Bump MAESTRO_VERSION in .env (or leave it at `latest` to track main)
docker compose -f infra/docker/docker-compose.prod.yml --env-file .env pull
docker compose -f infra/docker/docker-compose.prod.yml --env-file .env up -d
The API container re-runs migrations on startup; bootstrap is idempotent (won’t re-create existing agents).
Backups — Postgres data lives in the named Docker volume maestro_pgdata_prod. Snapshot strategy:
# Dump the DB
docker exec maestro-postgres pg_dump -U maestro maestro_prod > backup-$(date +%Y%m%d).sql
# Restore
cat backup-2026-05-05.sql | docker exec -i maestro-postgres psql -U maestro -d maestro_prod
Crucial: back up MAESTRO_SECRET_KEY separately from the DB dump. Without the key, the encrypted secrets in the dump are unrecoverable. See Security.
Stop everything:
docker compose -f infra/docker/docker-compose.prod.yml down
# Add --volumes to also delete the postgres data volume (destructive)
Alternative deployment targets
The same Docker images run on most modern hosts. The Cloudflare Tunnel + Access combo is specifically for residential / lab boxes without public IPs; on hosted platforms you don’t need the tunnel.
Linux VPS / DigitalOcean Droplet
Same docker compose invocation as the lab box. For ingress, install Caddy as a reverse proxy:
app.yourdomain.com {
reverse_proxy localhost:3001
basic_auth /* {
operator $2a$14$<bcrypt-hash>
}
}
Caddy handles TLS via Let’s Encrypt automatically. Use caddy hash-password to generate the bcrypt hash for basic auth.
Fly.io
Each container runs as a separate Fly app:
# In apps/api/
fly launch # detects Dockerfile, walks you through setup
fly secrets set MAESTRO_SECRET_KEY=... ANTHROPIC_API_KEY=... DATABASE_URL=...
# Same for apps/runtime/
Fly provides TLS and a public URL automatically. Use Fly Postgres for the database. Auth via Cloudflare Access in front of the Fly URL still works.
Railway
Push the repo, Railway detects the Dockerfiles and builds. Set the same env vars in the Railway dashboard. Add Railway Postgres as a service. Expose the API service publicly.
Maestro Cloud (planned)
When the managed cloud ships, the same images run on our infrastructure. Customers get the same agent + skill behaviour without managing Docker, the tunnel, or Postgres themselves.
Troubleshooting
docker compose pull 401s on GHCR.
The images are public; no auth needed. If you’re getting 401, you may have a stale Docker login. docker logout ghcr.io and retry.
API container restarts in a loop.
Check logs: docker compose -f infra/docker/docker-compose.prod.yml logs api. Most common causes:
MAESTRO_SECRET_KEYnot set, or set to an invalid value (must be base64-encoded 32 bytes).DATABASE_URLpoints at the wrong host. Inside the compose network it should bepostgres:5432, notlocalhost:5432.- Database migration failed — check the migration output in the logs.
Cloudflare Tunnel says “no service available”.
The tunnel can’t reach 127.0.0.1:3001. Verify the API container is up (docker ps) and the API binds to 127.0.0.1:3001 (the prod compose does). On Windows, also confirm the tunnel service is running on the same OS layer that can reach Docker Desktop’s port forwards.
OAuth callback fails with “redirect URI mismatch”.
The Google Cloud OAuth client doesn’t have your production callback URL. Add https://app.yourdomain.com/api/oauth/google/callback to the authorized redirect URIs (Step 5).
Cloudflare Access prompt loops.
Usually a third-party-cookie issue. Make sure your browser allows cookies for *.cloudflareaccess.com and app.yourdomain.com.
The web app loads but /api/* calls 404.
The container has WEB_DIST set but the catch-all SPA fallback is intercepting API routes. Verify your Cloudflare Access policy isn’t redirecting /api/* paths to login (it shouldn’t — Access wraps the whole app, not specific paths).
Related
- Security — what’s protected, what isn’t, what the operator is responsible for.
- B2B SaaS Outbound score — configuring the v1 hero score.
- Gmail skill setup — Google Cloud OAuth walkthrough.
- Apollo skill setup — API key generation.