Skip to content

Quickstart

Get a working Breeze instance running with a single docker compose up.

The guided installer downloads the templates, generates every required secret in the right format, checks that the version you pick has published container images, and starts the stack:

Terminal window
mkdir breeze && cd breeze
curl -fsSLO https://raw.githubusercontent.com/lanternops/breeze/main/scripts/guided-setup.sh
bash guided-setup.sh

On Linux the installer also offers to register a boot service (breeze-rmm) so the stack comes back up on its own after a reboot. Accept it on a server you want running unattended; you can install or update it later with bash guided-setup.sh --install-systemd.

Prefer to configure .env yourself? The manual path is below.

  1. Download the compose file and environment template

    Terminal window
    mkdir breeze && cd breeze
    curl -fsSLO https://raw.githubusercontent.com/lanternops/breeze/main/docker-compose.yml
    curl -fsSLO https://raw.githubusercontent.com/lanternops/breeze/main/.env.example
    # Caddy config — the compose file bind-mounts this, so it must exist on disk first
    curl -fsSL --create-dirs -o docker/Caddyfile.prod https://raw.githubusercontent.com/lanternops/breeze/main/docker/Caddyfile.prod
    cp .env.example .env
  2. Set your domain

    Terminal window
    # Domain (use "localhost" for local testing — see the headless note below)
    BREEZE_DOMAIN=localhost
    ACME_EMAIL=you@example.com
  3. Generate the secrets

    .env.example ships placeholder values for every secret. Replace them in place — edit the existing line for each key rather than appending a second copy at the end of the file.

    This one-liner rewrites each placeholder with a fresh value:

    Terminal window
    for key in JWT_SECRET SESSION_SECRET AGENT_ENROLLMENT_SECRET \
    APP_ENCRYPTION_KEY MFA_ENCRYPTION_KEY ENROLLMENT_KEY_PEPPER \
    MFA_RECOVERY_CODE_PEPPER METRICS_SCRAPE_TOKEN; do
    sed -i "s|^${key}=.*|${key}=$(openssl rand -hex 32)|" .env
    done
    # Must be canonical base64 decoding to >= 32 bytes, and must not reuse JWT_SECRET
    sed -i "s|^PARTNER_API_CURSOR_SIGNING_KEY=.*|PARTNER_API_CURSOR_SIGNING_KEY=$(openssl rand -base64 32)|" .env
    # Database and Redis. Redis auth is REQUIRED — the API refuses to start without it.
    POSTGRES_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=')"
    REDIS_PASSWORD="$(openssl rand -hex 32)"
    sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${POSTGRES_PASSWORD}|" .env
    sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=${REDIS_PASSWORD}|" .env
    sed -i "s|^REDIS_URL=.*|REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379|" .env

    On macOS, use sed -i '' instead of sed -i.

  4. Check the version pin

    .env.example pins BREEZE_VERSION to a specific release. Confirm it is the one you want before starting:

    Terminal window
    grep '^BREEZE_VERSION=' .env

    To upgrade later, set it to a newer release from the releases page and re-run docker compose up -d.

  5. Start Breeze

    Terminal window
    docker compose up -d

    On first boot the API container automatically:

    • Runs database migrations
    • Seeds the default admin user
    • Starts the background job workers
  6. Verify

    Terminal window
    # Wait for API to be healthy (~30s on first boot)
    docker compose logs -f api --since 1m

    Once you see Breeze API running, open the dashboard:

    • Dashboard: https://localhost (accept the self-signed cert)
    • Health check: curl -k https://localhost/health

Reaching the dashboard from another machine

Section titled “Reaching the dashboard from another machine”

BREEZE_DOMAIN becomes Caddy’s site address, and Caddy only answers requests whose Host header matches it. With BREEZE_DOMAIN=localhost the ports are published on all interfaces, but a browser on your laptop sends Host: 192.168.1.50, which matches nothing — so a minimal/headless server install has no reachable UI even though curl -k https://localhost/health returns OK on the box itself.

Pick whichever fits:

  • SSH tunnel — leave BREEZE_DOMAIN=localhost and forward the port:

    Terminal window
    ssh -L 8443:127.0.0.1:443 user@your-server
    # then browse to https://localhost:8443

    A browser origin includes the port, so https://localhost:8443 is a different origin from the https://localhost that the generated .env allows, and the API rejects sign-in requests coming from it. Pick one of:

    Terminal window
    # Forward the SAME port — 443 is privileged, so run this as root
    sudo ssh -L 443:127.0.0.1:443 user@your-server
    # then browse to https://localhost
    Terminal window
    # …or keep the high port and add the tunnel origin to .env on the server
    # (comma-separated, no spaces, no trailing paths)
    CORS_ALLOWED_ORIGINS=https://localhost,https://localhost:8443
    docker compose up -d api

    The API now accepts same-origin requests on its own, so this entry is optional — a browser reaching Breeze through the tunnel is already treated as same-origin. It stays correct either way.

  • LAN address — serve the machine’s IP or hostname directly:

    Terminal window
    BREEZE_DOMAIN=192.168.1.50
    PUBLIC_APP_URL=https://192.168.1.50
    DASHBOARD_URL=https://192.168.1.50

    Caddy cannot get a public certificate for a private address, so it serves its internal self-signed cert — accept the browser warning.

  • Internal domain — a name that resolves only on your LAN or VPN, or a host whose ports 80 and 443 are not reachable from the internet:

    Terminal window
    BREEZE_DOMAIN=breeze.mydomain.com
    PUBLIC_APP_URL=https://breeze.mydomain.com
    DASHBOARD_URL=https://breeze.mydomain.com
    CADDY_LOCAL_CERTS=local_certs

    Let’s Encrypt cannot validate a name it cannot reach, so CADDY_LOCAL_CERTS=local_certs switches Caddy to its own internal CA instead. The guided installer asks this for you — “Can Let’s Encrypt reach this domain?” — whenever you give it a domain that is not localhost or an IP.

    Browsers warn on every visit until that CA root is trusted on each client machine. Export it with:

    Terminal window
    docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt
  • Real domain — the production path. See Production Deploy.

Whichever you choose, PUBLIC_APP_URL must be the URL users actually visit: it is what agent installers and portal links are built from.

You are sent back to the login page with “session expired” right after signing in

  • Cause: the address in your browser’s address bar is not an origin this server accepts. CORS_ALLOWED_ORIGINS doesn’t list it, or PUBLIC_APP_URL is set to a different address than the one you are visiting. Reaching a BREEZE_DOMAIN=localhost install through an SSH tunnel on a different port is the usual way to land here — the password was fine, the sign-in worked, and the token refresh that follows it is what got rejected.
  • Confirm: open your browser devtools, go to the Network tab, and sign in again. POST /api/v1/auth/refresh returns 403 with the body {"error":"Invalid request origin"}. Newer Breeze builds also say so on the login page itself, naming the exact origin that was refused.
  • Fix: browse to the address in PUBLIC_APP_URL, or add the address you are actually using to CORS_ALLOWED_ORIGINS in .env (comma-separated, no spaces, no trailing paths) and restart the API with docker compose up -d api.

Chrome reports ERR_SSL_PROTOCOL_ERROR and Firefox SSL_ERROR_INTERNAL_ERROR_ALERT, with no certificate warning you can click through. That is Caddy aborting the TLS handshake with alert 80 (internal_error) because it holds no certificate for the name you asked for. Confirm it from the Caddy log:

Terminal window
docker logs breeze-caddy 2>&1 | grep tls.obtain

could not get certificate from issuer there means the Let’s Encrypt order for BREEZE_DOMAIN failed. Let’s Encrypt needs public DNS pointing at this host and ports 80 and 443 open from the internet — an internal-only name, or a host behind a firewall, can never pass validation.

Two ways out:

  • Make the domain publicly resolvable and open 80/443 inbound, then restart Caddy so it retries the order.

  • Or keep it internal and switch to Caddy’s own CA — set CADDY_LOCAL_CERTS=local_certs in .env (see Internal domain above) and restart Caddy:

    Terminal window
    docker compose up -d caddy
Container Port Purpose
breeze-caddy 80, 443 Reverse proxy + auto-TLS
breeze-api 3001 (internal) Hono API server
breeze-web 4321 (internal) Astro SSR dashboard
breeze-portal 4322 (internal) Customer portal, served under /portal
breeze-postgres 5432 (internal) PostgreSQL 16 database
breeze-redis 6379 (internal) Redis 7 (BullMQ + caching)
breeze-coturn 3478 (host) TURN relay for WebRTC remote desktop (only with --profile turn)

Download and install the Breeze agent on a device. BREEZE_SERVER must be the URL of your Breeze server as the target device can reach it — not localhost, which on the target device means the target device itself:

Terminal window
# On the target device:
# The enrollment token is REQUIRED — grab it from the Add Device dialog.
curl -fsSL https://breeze.example.com/api/v1/agents/install.sh | \
BREEZE_SERVER=https://breeze.example.com \
BREEZE_ENROLL_TOKEN=<your-enrollment-token> \
bash

If your server is configured with an org enrollment secret, pass it as an optional extra gate alongside the token (never instead of it):

Terminal window
curl -fsSL https://breeze.example.com/api/v1/agents/install.sh | \
BREEZE_SERVER=https://breeze.example.com \
BREEZE_ENROLL_TOKEN=<your-enrollment-token> \
BREEZE_ENROLLMENT_SECRET=<your-enrollment-secret> \
bash

See Agent Installation for detailed instructions per platform.

For production deployment with a real domain and monitoring, see Production Deploy.