Maestro

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:

  1. Docker Desktop — runs the Maestro stack (Postgres + API + runtime).
  2. Cloudflare Tunnel — gives Maestro a public URL without opening any inbound ports on your network.
  3. Cloudflare Access — gates the Maestro UI behind email-based SSO. No auth code in Maestro yet; Access handles it at the edge.
  4. Maestro itself — pulled from GHCR, configured via a .env file.

Prerequisites

  • Windows 11 Pro (Linux + macOS work too; deviations noted inline).
  • Docker Desktopdownload. 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_KEY separately. 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 as idle so nothing fires until you flip them on.
  • Starts the API server on port 3001 (bound to 127.0.0.1 only — 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:

  1. Download the latest cloudflared.exe from github.com/cloudflare/cloudflared/releases (pick cloudflared-windows-amd64.exe).
  2. Move it to C:\Program Files\cloudflared\cloudflared.exe.
  3. 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.comlocalhost: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.

  1. Cloudflare dashboard → Zero TrustAccessApplicationsAdd an applicationSelf-hosted.
  2. 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.
  3. Click Next, configure the policy:
    • Policy name: Operators
    • Action: Allow
    • Configure rulesIncludeEmails → list the email addresses of operators (you, design partners). Or Emails ending in to allow a whole domain.
  4. 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:

  1. APIs & ServicesCredentials → click the OAuth 2.0 client you set up for dev.
  2. Under Authorized redirect URIs, add: https://app.yourdomain.com/api/oauth/google/callback.
  3. Keep the localhost URI for dev. Save.

Now in Maestro:

  1. Sign in via Cloudflare Access.
  2. Secrets → add google_oauth_client_id and google_oauth_client_secret (same values you use in dev — or fresh ones if you want them isolated).
  3. Skills → Gmail → click Connect. Complete the Google consent flow on the production domain.

Step 6 — Configure your real cold-leads agent

  1. Agents → Cold leads · SaaS → Edit.
  2. Replace the seed sender (Nick from Maestro) and ICP with your own.
  3. 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.
  4. Add apollo_api_key to Secrets (see docs/skills/apollo.md for setup).
  5. Set the agent status to running or scheduled when 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_KEY not set, or set to an invalid value (must be base64-encoded 32 bytes).
  • DATABASE_URL points at the wrong host. Inside the compose network it should be postgres:5432, not localhost: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).