# ────────────────────────────────────────────────────────── # 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 # # 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 # ─── 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= # ══════════════════════════════════════════════════════════ # SaaS-only (CLOUD_MODE=true) # ══════════════════════════════════════════════════════════ # In CLOUD_MODE the API MUST use GitHub App auth (cli/token modes are rejected # at boot). Register a GitHub 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 (cloud runtime + billing). Required for cloud deploys / metering. # OBLIEN_CLIENT_ID= # OBLIEN_CLIENT_SECRET= # OBLIEN_WEBHOOK_SECRET= # HMAC for /api/billing/oblien-webhook (else 503) # Billing feature switches (SaaS-owned; self-hosted + local proxy to the cloud). # Both OFF by default → billing shows "coming soon" everywhere and every # Stripe-mutating endpoint returns 403. Flip on the SaaS to go live — no # dashboard or self-hosted release needed. Enable subscriptions first, top-ups # when ready (top-ups also require BILLING_ENABLED=true). BILLING_ENABLED=false BILLING_TOPUPS_ENABLED=false # # To actually TRANSACT once BILLING_ENABLED=true, Stripe must be configured # (the switch gates the feature; these gate real charges). Missing key → the # checkout endpoints throw "Stripe is not configured"; missing/placeholder # price id → 503 BILLING_NOT_CONFIGURED. # STRIPE_SECRET_KEY= # STRIPE_WEBHOOK_SECRET= # signs POST /api/billing/webhook/stripe # One price id per PUBLISHED price in packages/core/src/pricing/pricing.json. # Which vars are required is decided by the catalog, not by this file: each plan # NAMES its env var in `stripePriceEnv`, and boot logs every published price with # no id configured (loudly in CLOUD_MODE). An unset id → 503 # BILLING_NOT_CONFIGURED at checkout; a tier with no published price → 400 # BILLING_PLAN_NOT_PURCHASABLE. Monthly prices are live ($10/$39/$99); ANNUAL is # not published yet (annual.enabled=false), so the annual vars are unused until # it is — set them only alongside publishing annual prices in the catalog. # STRIPE_PRICE_STARTER_MONTHLY= # Starter, $10/mo # STRIPE_PRICE_PRO_MONTHLY= # Pro, $39/mo # STRIPE_PRICE_TEAM_MONTHLY= # Scale, $99/mo # STRIPE_PRICE_STARTER_ANNUAL= # STRIPE_PRICE_PRO_ANNUAL= # STRIPE_PRICE_TEAM_ANNUAL= # Top-up packs need only the real price ids (no null product-gate): # STRIPE_PRICE_PACK_5K= # STRIPE_PRICE_PACK_25K= # STRIPE_PRICE_PACK_100K= # # Promo codes are minted with the Stripe key above, from inside the API container: # docker compose exec api bun --cwd apps/api scripts/promo-code.ts create --percent 20 --code LAUNCH20 # Checkout already renders the redemption field (allow_promotion_codes). # # CAMPAIGNS (automatic, time-bounded discounts — e.g. 50% off at launch) are declared # in packages/core/src/pricing/pricing.json `campaigns[]`. Each one NAMES the env var # holding its Stripe coupon id; set that var here. Boot compares the catalog's # percentage against the live coupon and logs a mismatch, so the page can't advertise # 50% while Stripe bills 40%. NOTE a running campaign HIDES the promo-code box — # Stripe permits an automatic discount or a redeemable code, never both. # STRIPE_COUPON_LAUNCH50= # 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