From 52a98c194dca15ea1d4e6e0cdabd8b77718b24c4 Mon Sep 17 00:00:00 2001 From: Shani Singh Date: Fri, 24 Jul 2026 02:49:33 +0530 Subject: [PATCH] fix(docker): create the first admin declaratively on compose deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh `docker compose up` had no supported way to create the first administrator through the web flow: public sign-up is disabled, and the loopback bootstrap shortcut never fires because the request reaches the API from a Docker-bridge IP rather than 127.0.0.1 (issue #138). Add an optional one-shot `init-admin` service that calls the existing internal-token-gated `POST /api/system/bootstrap-admin` endpoint — the same path the CLI wizard uses — with credentials the operator declares in `.env` (`OPENSHIP_ADMIN_NAME` / `_EMAIL` / `_PASSWORD`). - No-op unless the admin vars are set, so nothing changes for operators who create the admin another way. - Does not enable public sign-up; reuses the invite-only bootstrap path. - Idempotent: bootstrap-admin returns 409 once an admin exists, which the script treats as success, so re-running compose never mutates an admin. Closes #138. --- .env.example | 11 ++++++ docker-compose.yml | 20 ++++++++++ docs/installation.md | 15 ++++++- scripts/docker-init-admin.sh | 76 ++++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100755 scripts/docker-init-admin.sh diff --git a/.env.example b/.env.example index 3a296213..5b6e971b 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,17 @@ BETTER_AUTH_URL=http://localhost:4000 # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" INTERNAL_TOKEN=change-me-32-byte-random-hex +# ─── First admin (Docker Compose only, optional) ────────── +# On the CLI (`openship`) the setup wizard creates the first admin. Docker has no +# wizard, so set these to have the one-shot `init-admin` service create the first +# admin on `docker compose up` (issue #138). Leave unset to create it another way +# (CLI, or a first sign-in you enable manually). Public sign-up stays disabled — +# this calls the same internal-token-gated bootstrap endpoint the CLI uses, and +# is idempotent (it won't touch an admin that already exists). Password: 8-128 chars. +# OPENSHIP_ADMIN_NAME=Admin +# OPENSHIP_ADMIN_EMAIL=admin@example.com +# OPENSHIP_ADMIN_PASSWORD= + # ─── OAuth login (optional) ─────────────────────────────── # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= diff --git a/docker-compose.yml b/docker-compose.yml index 1c445712..06424c06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,6 +107,26 @@ services: api: condition: service_healthy + # ─── First-admin bootstrap (one-shot, optional) ─────── + # Creates the first administrator declaratively so a fresh `docker compose up` + # is usable without the CLI wizard (issue #138). No-op unless OPENSHIP_ADMIN_* + # is set in .env; idempotent (bootstrap-admin is one-shot — 409 once an admin + # exists). Does NOT enable public sign-up — it calls the same internal-token- + # gated endpoint the CLI uses. Runs after the API is healthy, then exits. + init-admin: + image: curlimages/curl:8.11.1 + restart: "no" + env_file: + - .env + environment: + OPENSHIP_API_URL: http://api:4000 + volumes: + - ./scripts/docker-init-admin.sh:/docker-init-admin.sh:ro + entrypoint: ["sh", "/docker-init-admin.sh"] + depends_on: + api: + condition: service_healthy + # ─── Web / Landing ──────────────────────────────────── web: build: diff --git a/docs/installation.md b/docs/installation.md index 94ae7a15..44434661 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -64,11 +64,24 @@ cp .env.example .env docker compose up -d ``` +**Create the first admin.** Docker has no interactive setup wizard, so declare the +first administrator in `.env` before starting — the one-shot `init-admin` service +creates it once the API is healthy (public sign-up stays disabled): + +```bash +OPENSHIP_ADMIN_NAME=Admin +OPENSHIP_ADMIN_EMAIL=admin@example.com +OPENSHIP_ADMIN_PASSWORD=a-strong-password # 8-128 chars +``` + +It's idempotent — re-running `docker compose up` never changes an existing admin. +Leaving these unset skips the step; you can still create the admin another way. + --- ## Users & access (self-hosted) -- **Public signup is disabled.** The first admin is created by `openship up`'s setup wizard. +- **Public signup is disabled.** On the CLI the first admin is created by `openship up`'s setup wizard; on Docker, set `OPENSHIP_ADMIN_*` in `.env` (see [Docker](#docker) above). - **Invite teammates** from **Settings → Team**. They get an accept link at your instance's URL (so the instance needs to be reachable — a public URL or your LAN). - **Lost the admin password?** Run `openship reset-admin-password` on the box (no sign-in needed; uses the local internal token). diff --git a/scripts/docker-init-admin.sh b/scripts/docker-init-admin.sh new file mode 100755 index 00000000..a19cc47c --- /dev/null +++ b/scripts/docker-init-admin.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────────── +# Declarative first-admin bootstrap for the Docker Compose stack. +# +# Compose has no equivalent of `openship up`'s interactive setup wizard, so a +# fresh `docker compose up` used to leave the instance with no way to create the +# first administrator through the web flow (public sign-up is disabled, and the +# loopback bootstrap shortcut never fires because the request reaches the API +# from a Docker-bridge IP, not 127.0.0.1). See issue #138. +# +# This one-shot service closes that gap WITHOUT enabling public sign-up: it +# calls the existing internal-token-gated `POST /api/system/bootstrap-admin` +# endpoint — the same endpoint the CLI wizard uses — with the credentials the +# operator declared in `.env`. +# +# It is a no-op unless OPENSHIP_ADMIN_EMAIL and OPENSHIP_ADMIN_PASSWORD are set, +# and it is idempotent: bootstrap-admin refuses (409) once an admin exists, so +# re-running `docker compose up` never changes an existing admin. Operators who +# prefer the CLI or a manual first sign-in can simply leave the vars unset. +# ───────────────────────────────────────────────────────────────────────────── +set -eu + +API_URL="${OPENSHIP_API_URL:-http://api:4000}" +ADMIN_NAME="${OPENSHIP_ADMIN_NAME:-Admin}" + +if [ -z "${OPENSHIP_ADMIN_EMAIL:-}" ] || [ -z "${OPENSHIP_ADMIN_PASSWORD:-}" ]; then + echo "[init-admin] OPENSHIP_ADMIN_EMAIL / OPENSHIP_ADMIN_PASSWORD not set — skipping declarative admin bootstrap." + echo "[init-admin] Create the first admin with the CLI, or set those vars in .env and re-run \`docker compose up\`." + exit 0 +fi + +if [ -z "${INTERNAL_TOKEN:-}" ]; then + echo "[init-admin] INTERNAL_TOKEN is not set. The API requires it and so does this bootstrap. Aborting." >&2 + exit 1 +fi + +# Build the JSON body with a tiny Python helper if available; otherwise fall back +# to a manual escape. curl images ship neither jq nor python, so escape inline. +escape() { + # Escape backslash, double-quote and control chars for a JSON string value. + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +body="{\"name\":\"$(escape "$ADMIN_NAME")\",\"email\":\"$(escape "$OPENSHIP_ADMIN_EMAIL")\",\"password\":\"$(escape "$OPENSHIP_ADMIN_PASSWORD")\"}" + +echo "[init-admin] Bootstrapping first admin for ${OPENSHIP_ADMIN_EMAIL} via ${API_URL} …" + +status="$( + curl -sS -o /tmp/init-admin-response -w '%{http_code}' \ + -X POST "${API_URL}/api/system/bootstrap-admin" \ + -H 'Content-Type: application/json' \ + -H "X-Internal-Token: ${INTERNAL_TOKEN}" \ + --data "${body}" +)" || { + echo "[init-admin] Could not reach the API at ${API_URL}. Is the api service healthy?" >&2 + exit 1 +} + +case "${status}" in + 200) + echo "[init-admin] First admin created for ${OPENSHIP_ADMIN_EMAIL}. Sign in at the dashboard." + ;; + 409) + echo "[init-admin] An admin already exists — nothing to do (bootstrap is one-shot)." + ;; + 401) + echo "[init-admin] API rejected the internal token (401). Ensure INTERNAL_TOKEN matches the api service." >&2 + exit 1 + ;; + *) + echo "[init-admin] bootstrap-admin failed (HTTP ${status}):" >&2 + cat /tmp/init-admin-response >&2 2>/dev/null || true + echo >&2 + exit 1 + ;; +esac