# ────────────────────────────────────────────────────────── # Openship — environment reference (self-hosted AND SaaS) # # cp .env.example .env && docker compose up -d --build # # The SAME file drives both modes — flip CLOUD_MODE + fill the SaaS section. # docker-compose overrides DATABASE_URL / REDIS_URL with in-cluster service # DNS, so the localhost values here are for running the apps WITHOUT Docker. # ────────────────────────────────────────────────────────── NODE_ENV=production # ─── Mode (the ONLY switch — one compose stack, env decides) ── # false = self-hosted (default, no billing) | true = SaaS (billing, metering, multi-tenant) CLOUD_MODE=false # docker (default, self-hosted) | cloud (SaaS) DEPLOY_MODE=docker # SaaS only: hard cap on projects per user (cloud org = one owning user). # Enforced at project create + ensure. Self-hosted ignores this. Default 2. CLOUD_MAX_PROJECTS_PER_USER=2 # Runtime URL row (packages/core/runtime-config.ts). Leave unset for self-hosted. # For the SaaS set: OPENSHIP_TARGET=cloud-saas (app.openship.io / api.openship.io) # OPENSHIP_TARGET=local # ─── Storage (Postgres) ─────────────────────────────────── # Set DATABASE_URL → Postgres driver. Leave empty → PGlite embedded (dev only; # NOT for a multi-tenant SaaS). Compose builds this from POSTGRES_* below. DATABASE_URL=postgresql://openship:openship@localhost:5432/openship POSTGRES_USER=openship POSTGRES_PASSWORD=openship POSTGRES_DB=openship # PGLITE_DATA_DIR=/var/lib/openship/data # ─── Redis (queue + cache + rate-limit) ─────────────────── REDIS_URL=redis://localhost:6379 # Force the Redis-backed job runner / cache / rate-limiter and DISABLE the # silent in-memory fallback. Defaults ON when CLOUD_MODE=true; set true to force # it for self-hosted multi-replica too. false = auto-probe (single-box dev). OPENSHIP_REQUIRE_REDIS=true # Per-subsystem overrides (rarely needed): in-process|bullmq / memory|redis # OPENSHIP_JOB_RUNNER=bullmq # OPENSHIP_CACHE_STORE=redis # OPENSHIP_RATE_LIMIT_STORE=redis # ─── Auth (Better Auth) ─────────────────────────────────── # CHANGE BOTH SECRETS below before exposing this instance. Generate each with: # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" BETTER_AUTH_SECRET=change-me-in-production # Internal-auth token fronting trusted endpoints. REQUIRED for any non-desktop # deploy — the API REFUSES to boot without it. INTERNAL_TOKEN=change-me-32-byte-random-hex # SaaS cross-subdomain SSO (must start with "." and be a parent of the API host). # Not needed for a self-hosted single-origin install. # BETTER_AUTH_COOKIE_DOMAIN=.openship.io # ─── Remote access (LAN IP or reverse-proxy domain) ────── # By default only http://localhost:3001 is trusted. To reach Openship from # ANOTHER machine — a reverse proxy on a different host, or a browser elsewhere # on your LAN — set OPENSHIP_PUBLIC_URL to the EXACT origin the browser uses # (include a non-standard port). Without it the dashboard loads but LOGIN is # rejected with 403 ORIGIN_REJECTED. The browser only ever talks to the # dashboard (:3001); the API stays internal behind the same-origin /api/proxy. # OPENSHIP_PUBLIC_URL=http://192.168.1.50:3001 # OPENSHIP_PUBLIC_URL=https://openship.example.com # # Behind a reverse proxy, trust its forwarded client IP — otherwise every user # is keyed to the proxy's IP, sharing ONE rate-limit bucket, and can lock each # other out of login. The proxy must set X-Real-IP to the real client. # TRUST_PROXY=true # If the upstream edge already rate-limits API traffic, delegate the coarse # pre-auth flood ceiling to it. Per-route user/auth limits remain enabled. # Cloud mode implies this trust; standalone APIs keep the guard by default. # OPENSHIP_TRUST_EDGE=false # # Additional browser origins to trust for CORS + CSRF (comma-separated) — only # needed when more than one host reaches the app. Same scheme+host+port the # browser uses. (OPENSHIP_PUBLIC_URL is already trusted automatically.) # OPENSHIP_EXTRA_TRUSTED_ORIGINS=http://192.168.1.50:3001 # Which host interface docker compose publishes the ports on. Default = all # interfaces (0.0.0.0). Set to a specific LAN IP to publish only there. # OPENSHIP_BIND_ADDR=192.168.1.50 # Ports docker compose publishes (host + container move together). Defaults # shown — override to avoid conflicts with something already on the host. # API_PORT=4000 # DASHBOARD_PORT=3001 # Certificate authority / ACME (self-hosted managed edge) # Defaults are unchanged when these are omitted: Let's Encrypt production, # Certbot's default EC key, and automatic renewal. # OPENSHIP_ACME_EMAIL=ops@example.com # OPENSHIP_ACME_DIRECTORY_URL=https://acme.zerossl.com/v2/DV90 # EAB credentials must be set together. The HMAC key must be base64url encoded. # OPENSHIP_ACME_EAB_KID= # OPENSHIP_ACME_EAB_HMAC_KEY= # OPENSHIP_ACME_KEY_TYPE=ec256 # ec256 | ec384 | rsa2048 | rsa4096 # OPENSHIP_ACME_CA_BUNDLE=/etc/ssl/private/acme-root.pem # OPENSHIP_ACME_TOS_AGREED=true # See docs/acme.md for ZeroSSL, private-CA, and container mount examples. # WEB_PORT=3000 # landing site — root control-plane compose (docker-compose.yml) only # ─── Image source (self-hosted pull-based compose) ─────── # The self-hosted stack (docker/docker-compose.yml) PULLS these published images # — no local build. Registry: ghcr.io/oblien (GitHub Container Registry — where # the official images are published). OPENSHIP_IMAGE_REGISTRY overrides it if you # mirror the images elsewhere. Pin OPENSHIP_VERSION to a release (e.g. 0.2.3) for # reproducible upgrades; `latest` tracks the newest release. To build that stack # from source, add docker/docker-compose.build.yml. (The root docker-compose.yml # control plane always builds from source.) # OPENSHIP_IMAGE_REGISTRY=ghcr.io/oblien # OPENSHIP_VERSION=latest # ─── Host Docker socket ─────────────────────────────────── # The API drives the edge and every deployed app container through the host's # Docker daemon, mounted into the api container at /var/run/docker.sock (which is # dockerode's own default, so only the HOST side of that mount is configurable). # The host side MOVES: a rootless daemon runs as the invoking user and keeps its # socket under that user's runtime dir, e.g. /run/user/1000/docker.sock. # # `openship up` resolves it from $DOCKER_HOST, the active docker context, or the # rootless runtime dirs, and writes this key only when the answer isn't the default # — set it by hand when that answer is wrong, or for a raw `docker compose` install. # Getting it wrong does NOT fail loudly: Docker creates a missing bind-mount source # as an empty DIRECTORY, so the stack comes up healthy and every container # operation then fails in the transport, naming no path at all (#482). Yours: # docker context inspect --format '{{.Endpoints.docker.Host}}' # OPENSHIP_DOCKER_SOCKET=/var/run/docker.sock # Abort a Docker image build after this many milliseconds with no output from # the builder. This bounds genuinely stalled/OOM-thrashing builds without # guessing from unrelated host containers. Default 10 minutes; accepted range # is 1 minute to 24 hours. # OPENSHIP_BUILD_IDLE_TIMEOUT_MS=600000 # ─── Host operations from the container (optional) ──────── # The edge (routing/TLS) runs as the `edge` container; app containers run via # the mounted docker socket. For the few HOST-OS ops a container can't do to its # host (freeing a foreign proxy off :80/443, host system config, the mail engine, # writing a catalog app's generated config file), the API reaches the host over # SSH via host.docker.internal (internal bridge, not the public IP). # # Leaving this unset does NOT degrade to running those ops locally: inside a # container "locally" is the container's own filesystem, so they REFUSE instead, # naming this channel. Ordinary deploys are unaffected — they go through the # docker socket. On a CLI install `openship doctor` reports which state you're # in; on a raw `docker compose` install it can't see the stack, so the signal is # the api's boot log (a `!!! HOST CONTROL …` banner, or silence when it's fine). # # `openship up` provisions all of it. By hand, on docker/docker-compose.yml, the # vars are the LAST step, not the only one — all five are needed: # 1. sudo mkdir -p /var/lib/openship/host-ssh # sudo ssh-keygen -t ed25519 -N '' -C openship-host-executor \ # -f /var/lib/openship/host-ssh/id_ed25519 # 2. append the .pub to the authorized_keys of the user below — root, for the # root-owned paths host ops touch — as ONE restricted line: # printf 'from="172.16.0.0/12,192.168.0.0/16,10.0.0.0/8,127.0.0.1",restrict,pty %s\n' \ # "$(sudo cat /var/lib/openship/host-ssh/id_ed25519.pub)" \ # | sudo tee -a /root/.ssh/authorized_keys # (`from=` matters: without it that key is a root login from anywhere sshd # accepts. `pty` is added back because the host terminal needs one.) # 3. sshd must be listening on an address the containers can reach — a # ListenAddress pinned to 127.0.0.1 refuses this channel and nothing else. # 4. allow container→host on the SSH port in the host's firewall: this # address is host-local, so it traverses filter/INPUT where a default-deny # ufw lives — published container ports are DNAT'd and skip it, which is # why the rest of the stack looks healthy while this one hangs. # 5. set the vars below, then recreate the api — `env_file:` is read when a # container is CREATED, so a restart alone changes nothing: # docker compose --env-file .env -f docker/docker-compose.yml \ # up -d --force-recreate --no-deps api # # Full walkthrough, including the repair for an install that reports success and # then fails its first host operation: # https://openship.io/docs/troubleshooting/host-channel # OPENSHIP_HOST_SSH_HOST=host.docker.internal # OPENSHIP_HOST_SSH_USER=root # OPENSHIP_HOST_SSH_PORT=22 # What `host.docker.internal` resolves to INSIDE the container. Unset means the # daemon's own `host-gateway`, which is right on a stock rootful install and wrong # under rootless Docker: there it lands in RootlessKit's namespace rather than the # host's, so the host's sshd is not at that address (#482). Point it at the box's own # LAN/bridge address in that case — or set OPENSHIP_HOST_SSH_HOST to that address # directly. `openship up` carries whichever you set across re-runs. # OPENSHIP_HOST_GATEWAY=10.0.0.108 # In-container path of the key. Its SOURCE on the host is OPENSHIP_HOST_KEY_PATH # below, which docker/docker-compose.yml mounts here — so no compose file needs # editing. Unset OPENSHIP_HOST_KEY_PATH mounts /dev/null instead, which is what # lets the stack start on a box with no key at all. # OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key # ABSOLUTE path, always: a relative one resolves against docker/, not the # directory you run `docker compose` from. # OPENSHIP_HOST_KEY_PATH=/var/lib/openship/host-ssh/id_ed25519 # Set to false to switch host control off deliberately: no key is used, host ops # refuse, and this box stops being offered as a deploy target. The docker socket # is still mounted (deploys need it), so this is defense in depth, not isolation. # OPENSHIP_HOST_CONTROL=true # ─── OAuth login (optional) ─────────────────────────────── # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= # ─── Self-hosted GitHub App (optional, no Openship Cloud/PAT required) ─────── # Configure ALL fields together. In GITHUB_AUTH_MODE=auto a complete local App # takes priority over the cloud App and gh/PAT fallbacks. Keep the PEM and # webhook secret only in this server-side env file; they are never stored in DB. # See: https://openship.io/docs/guides/self-hosted-github-app # GITHUB_AUTH_MODE=auto # GITHUB_APP_ID= # GITHUB_APP_SLUG=your-openship-app-slug # GITHUB_PRIVATE_KEY_BASE64= # base64 of the App's .pem private key # GITHUB_WEBHOOK_SECRET= # ══════════════════════════════════════════════════════════ # Openship SaaS (CLOUD_MODE=true) # ══════════════════════════════════════════════════════════ # In CLOUD_MODE the API always uses the canonical GitHub App (every non-App # auth mode is rejected at boot). Register the Openship App and fill these: # GITHUB_APP_ID= GITHUB_APP_SLUG=openship-io # GITHUB_PRIVATE_KEY_BASE64= # base64 of the App's .pem private key # GITHUB_WEBHOOK_SECRET= # Oblien owns Cloud checkout, subscriptions, credits, and metering. # Set these in the deployed API environment. Both Compose stacks load this # root .env; they do NOT load apps/api/.env.saas (used by dev:saas). # Use the production account's credentials for the production SaaS. # OBLIEN_API_URL=https://api.oblien.com # OBLIEN_CLIENT_ID= # OBLIEN_CLIENT_SECRET= # OBLIEN_WEBHOOK_SECRET= # random signing secret; API registers it at startup # Optional if the runtime API origin already resolves to the deployed API: # OBLIEN_WEBHOOK_URL=https://api.openship.io/api/billing/oblien-webhook # Billing feature switches (SaaS-owned; self-hosted + local proxy to the cloud). # Both OFF by default. Set BILLING_ENABLED=true to accept subscriptions and # BILLING_TOPUPS_ENABLED=true to also accept credit purchases, then recreate # the API container. These are global switches, not per-organization settings. # Billing state, usage, cancellation, and portal access remain available when # purchases are off. A failed billing-state read is a separate setup/API issue. BILLING_ENABLED=false BILLING_TOPUPS_ENABLED=false # Optional product analytics for the hosted production Cloud API only. # Never initialized in desktop, self-hosted, development, or test modes. # Uses the PostHog PROJECT token (phc_...), not a personal API key. POSTHOG_ENABLED=false # POSTHOG_PROJECT_KEY=phc_... POSTHOG_HOST=https://us.i.posthog.com # For an EU PostHog project: https://eu.i.posthog.com # Exclude internal/demo accounts before enabling. Organization exclusions also # cover background deployments, subscription snapshots and billing webhooks. # POSTHOG_EXCLUDED_ORGANIZATION_IDS=org_internal,org_demo # POSTHOG_EXCLUDED_USER_IDS=user_internal # See docs/cloud-product-analytics.md for the event catalog and launch reports. # Prices and payment configuration come from Oblien. Openship does not need # Stripe keys, price IDs, or coupons for this billing flow. # See docs/openship-cloud-launch.md for setup, activation, and diagnostics. # Transactional email (optional) # SMTP_HOST= # SMTP_PORT=587 # SMTP_USER= # SMTP_PASS= # SMTP_FROM=Openship # Cloud waitlist (SaaS only): where the dashboard's "notify me" deploy gate # forwards emails. Read server-side by /api/cloud-waitlist; unset = accept # silently (no forward). Point at your marketing/waitlist submit endpoint. # MARKETING_API_URL=https://marketing.example.com/api/waitlist SYSTEM_DEBUG_LOGS=false