# syntax=docker/dockerfile:1.7 # Crewship -- Single Binary Production Dockerfile # Multi-stage: Go build + Next.js static export → minimal Alpine image # Image: ghcr.io/crewship-ai/crewship:latest # # BuildKit cache mounts persist the pnpm store and Go module/build cache # across `docker build` invocations even when the build context layer is # invalidated, dramatically speeding up incremental builds. Requires # BuildKit (default on modern Docker; enable with DOCKER_BUILDKIT=1 on # old daemons) and the syntax directive above. # Node 22 to match CI (NODE_VERSION: "22" in .github/workflows) and the # support matrix of the pinned prisma 7.8.0, whose preinstall only accepts # Node 20.19+/22.12+/24.0+. On node:26-alpine the Prisma CLI's WASM schema # parser corrupts every schema line to `""` under the emulated linux/arm64 # build, so `pnpm prisma generate` dies with a wall of P1012 "This line is # invalid" errors even though the schema is valid. Bump only in lockstep # with CI + a Prisma release that declares Node 26 support. FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend # Newer node-alpine images stopped bundling corepack by default, so # `corepack enable` alone can fail with "corepack: not found". Install it # explicitly first; `pnpm install` below then picks up the exact version # pinned in package.json's `packageManager` field, same as CI's # pnpm/action-setup (see .github/actions/setup-node-pnpm/action.yml). RUN npm install -g corepack@0.34.1 && corepack enable pnpm WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ pnpm install --frozen-lockfile # Generate types and legal inventory before app sources: a UI edit must not # redo work determined solely by Prisma inputs or the locked dependency tree. # .npmrc stays after install, matching the existing install configuration. COPY .npmrc prisma.config.ts ./ COPY prisma/ ./prisma/ RUN pnpm prisma generate COPY scripts/gen-frontend-licenses.mjs ./scripts/ # License texts of the embedded frontend's npm dependencies, from the # lockfile-installed tree (no network). Collected in-stage so every docker # build path — PR image build, release, nightly, local — is self-contained. RUN node scripts/gen-frontend-licenses.mjs /licenses-frontend # Backend-only sources leave these COPY layers cached. VERSION still forces # a fresh static export when the build identity changes. # Root TS configs and e2e sources remain inputs because tsconfig.json includes # them in Next's type check. The source contract tests guard new TS inputs. COPY *.ts *.json *.mjs ./ COPY app/ ./app/ COPY components/ ./components/ COPY hooks/ ./hooks/ COPY lib/ ./lib/ COPY stores/ ./stores/ COPY public/ ./public/ # Shared JSON contracts are imported by the UI; exclude their Go sources. COPY config/*.json ./config/ COPY schemas/*.json ./schemas/ COPY schemas/wordcraft/ ./schemas/wordcraft/ COPY internal/pages/content_types.json ./internal/pages/ COPY internal/slides/lucide_icons.json ./internal/slides/ COPY mooring/client/ ./mooring/client/ COPY mooring/protocol/generated/ts/ ./mooring/protocol/generated/ts/ COPY e2e/ ./e2e/ # Reference editors are TypeScript inputs checked by the production build. COPY examples/ ./examples/ COPY scripts/prepare-pdf-assets.mjs scripts/prepare-chat-workers.mjs scripts/check-control-accessibility.tsx ./scripts/ COPY tools/pages-build/sdk.ts ./tools/pages-build/ # Standalone registry tooling is also part of the root TypeScript input graph. COPY tools/wordcraft-capabilities/ ./tools/wordcraft-capabilities/ # Explicit C-E acceptance files participate in the root TypeScript graph. COPY e2e/wordcraft-ce/ ./e2e/wordcraft-ce/ COPY tools/wordcraft-ce/ ./tools/wordcraft-ce/ COPY playwright.wordcraft-ce.config.ts ./ # The browser instantiates this shared closed model with its own pinned Yjs. # Copy only the closed schema modules, without the separate Docs service runtime. COPY tools/docs-service/schema-core.mjs tools/docs-service/position-migrations.mjs tools/docs-service/surface-core.mjs ./tools/docs-service/ ARG VERSION=dev ARG NEXT_PUBLIC_SENTRY_DSN="" ENV NEXT_PUBLIC_CREWSHIP_VERSION=$VERSION RUN pnpm build FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine AS backend ARG TARGETOS ARG TARGETARCH # This tag is the compiler for the shipped binary, and it is the one Go version # no pull-request check ever exercises: the image is built by release.yml and # nightly.yml only (#2064). Dependabot's `docker-images` group bumps it alone. # # `local` makes the tag above the single authority: the go command uses the # toolchain this image ships and never downloads another. Without it the # default is `auto`, which honours go.mod's `toolchain` directive by FETCHING # that compiler — so a `toolchain` bump would quietly build the release with a # Go that no line in this repo pins and no vuln scan ever graded. # # Deliberately not a version literal. `GOTOOLCHAIN=go1.27.0` would be one more # copy of the string to keep in sync, and it fails the wrong way round: bump # the FROM tag above, forget the literal, and Go downloads the OLD toolchain # and undoes the bump while every check stays green. `local` cannot do that. # # What `local` does NOT do — verified against this image, not assumed — is # object when go.mod's `toolchain` names something else. That directive is # ignored outright under `local`, so a `toolchain go1.27.3` against this 1.27.2 # tag builds happily and silently with 1.27.2. Only the `go` DIRECTIVE can fail # the build ("go.mod requires go >= 1.28; running go 1.27.2"), and that line # tracks the language floor, which moves only when a dependency forces it and # so cannot be relied on here. So nothing at build time will ever tell us these # two files disagree — which is exactly why the disagreement is checked # statically instead, by scripts/go-toolchain-pin.sh, on every PR. # # The official golang-alpine images already default to `local`, so this line # changes nothing today. It is written down because an inherited upstream # default is a fact that happens to hold, not an invariant: stated here it # survives a base-image swap, and the guard can check it alongside the FROM # tag, go.mod's `toolchain` directive and every GO_VERSION in .github/. ENV GOTOOLCHAIN=local WORKDIR /app COPY go.mod go.sum ./ RUN --mount=type=cache,id=go-mod,target=/go/pkg/mod \ go mod download COPY cmd/ ./cmd/ COPY internal/ ./internal/ # Public schema and generated Go wire types; no Rust compiler or vendor tree. COPY mooring/protocol/ ./mooring/protocol/ # CLI embeds the supported Pages dependency lock and source starter. COPY tools/ ./tools/ COPY examples/ ./examples/ # config/ is the embedded model catalog (config/embed.go + models.json), # imported by internal/llm since #2305 — a root-level package like schemas/, # and missing from this stage the same way (#2328). scripts/dockerfile-sources # fails the Go suite when a root package the binaries import is not copied. COPY config/ ./config/ # schemas/ is a root-level package (embed.go + routine.v1.json) imported by # internal/api and cmd/crewship (#849). It must be copied like any other # source dir or the in-image `go build ./cmd/crewship` fails to resolve # github.com/crewship-ai/crewship/schemas (#886). Regular CI hides this # because it builds from a full checkout; this stage copies dirs selectively. COPY schemas/ ./schemas/ # config/ is the second such root-level package (embed.go + models.json + # rate-limits.yml), imported by internal/llm since #2305. Same rule as # schemas/: it is not under cmd/ or internal/, so it has to be copied by # name or the in-image build fails to resolve # github.com/crewship-ai/crewship/config — which is exactly how the # 2026-09-04 nightly image build broke while every PR check stayed green. # The PR image build (#2064) now catches this class before merge, and # scripts/pr-image-build-paths.sh keeps its path filter in step with the # COPY lines here. COPY web/ ./web/ COPY --from=frontend /app/out ./web/out # Release gate (#1567). web/out/ now always compiles — a tracked placeholder # keeps `//go:embed all:out` resolvable in a bare checkout — so the image # build has to prove the frontend stage actually delivered a real export # instead of letting a UI-less crewship image ship quietly. scripts/ is not # copied into this stage, so the check is inlined rather than calling # scripts/embed-web-out.sh; _next/ is what a Next.js export always emits and # a placeholder or hand-rolled stub never does. RUN test -f ./web/out/index.html && test -d ./web/out/_next \ || (echo "ERROR: web/out/ has no Next.js static export — refusing to build an image with no web UI" >&2; exit 1) ARG VERSION=dev ARG COMMIT=none ARG DATE=unknown # SENTRY_DSN is intentionally a build-arg with empty default. The release # workflow passes --build-arg SENTRY_DSN=$SENTRY_DSN from the GH secret; # local `docker build` produces a binary with telemetry hard-off (the # crashreport package treats empty DSN as "stay disabled regardless of # opt-in" so dev images never phone home). ARG SENTRY_DSN="" # The daemon validates the bundled sidecar even in --no-docker mode. # Build it for the same target architecture as the server. RUN --mount=type=cache,id=go-mod,target=/go/pkg/mod \ --mount=type=cache,id=go-build,target=/root/.cache/go-build \ go run ./tools/gen-licenses -out /licenses-go RUN --mount=type=cache,id=go-mod,target=/go/pkg/mod \ --mount=type=cache,id=go-build,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \ -ldflags="-s -w" -o /crewship-sidecar ./cmd/crewship-sidecar # -trimpath strips workspace paths from binary debug info — same # rationale as the Makefile / goreleaser changes: reproducible builds # so cosign-verified hashes match across builders. RUN --mount=type=cache,id=go-mod,target=/go/pkg/mod \ --mount=type=cache,id=go-build,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \ -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE} -X github.com/crewship-ai/crewship/internal/crashreport.DSN=${SENTRY_DSN}" \ -o /crewship ./cmd/crewship # The workbook test barrier exists only in binaries built with the # crewship_testbarrier tag. No image may carry it, whatever build args or # GOFLAGS were used; the same guard checks every release archive. COPY scripts/check-no-test-barrier.sh /usr/local/bin/check-no-test-barrier RUN sh /usr/local/bin/check-no-test-barrier /crewship /crewship-sidecar # -- Runner -- # The docker CLI (page builds, devcontainer images) comes from Docker's static # release, not Alpine's docker-cli package: Alpine rebuilds that package on its # own schedule, and alpine:3.24 shipped it built with go1.26.8, which the image # scan fails critical on (#3047). Bump the version and both checksums together; # check the archive's Go with `strings docker/docker | grep -m1 '^go1\.'`. FROM alpine:3.24 AS dockercli ARG TARGETARCH ARG DOCKER_CLI_VERSION=29.9.0 ARG DOCKER_CLI_SHA256_amd64=33e1ab8b63d14bca449f7a3d30d7f6aa669daa544e60a86b17fec87f61afe2b4 ARG DOCKER_CLI_SHA256_arm64=811f6eee271678fc98dbe5c4d896c00fc5fe2daa4f7a37069c17abc036eab85b RUN set -eu; \ case "$TARGETARCH" in \ amd64) arch=x86_64; sum="$DOCKER_CLI_SHA256_amd64" ;; \ arm64) arch=aarch64; sum="$DOCKER_CLI_SHA256_arm64" ;; \ *) echo "no pinned docker CLI for $TARGETARCH" >&2; exit 1 ;; \ esac; \ wget -q -O /tmp/docker.tgz "https://download.docker.com/linux/static/stable/${arch}/docker-${DOCKER_CLI_VERSION}.tgz"; \ echo "${sum} /tmp/docker.tgz" | sha256sum -c -; \ tar -xzf /tmp/docker.tgz -C /tmp docker/docker; \ install -m 0755 /tmp/docker/docker /docker; \ /docker --version FROM alpine:3.24 AS runtime ARG VERSION=dev ARG COMMIT=none ARG DATE=unknown LABEL org.opencontainers.image.source="https://github.com/crewship-ai/crewship" \ org.opencontainers.image.version=$VERSION \ org.opencontainers.image.revision=$COMMIT \ org.opencontainers.image.created=$DATE # Refresh inherited libraries too: apk add alone retains vulnerable base packages. RUN apk --no-cache upgrade && \ apk --no-cache add ca-certificates git && \ addgroup -g 1001 -S crewship && adduser -u 1001 -S crewship -G crewship RUN mkdir -p /var/lib/crewship /var/log/crewship /data && \ chown -R crewship:crewship /var/lib/crewship /var/log/crewship /data COPY --from=dockercli /docker /usr/local/bin/docker COPY --from=backend /crewship /usr/local/bin/crewship COPY --from=backend /crewship-sidecar /usr/local/bin/crewship-sidecar COPY scripts/entrypoint.sh /usr/local/bin/entrypoint.sh # Distribution legal files, produced inside the frontend/backend stages # (self-contained: PR image builds and local docker builds need no host # pre-step). The test refuses an image without them, so no supported build # path ships license-less. COPY LICENSE NOTICE /usr/share/doc/crewship/ COPY --from=backend /licenses-go /usr/share/doc/crewship/licenses/go COPY --from=frontend /licenses-frontend /usr/share/doc/crewship/licenses/frontend RUN test -f /usr/share/doc/crewship/NOTICE \ && test -d /usr/share/doc/crewship/licenses/go \ && test -f /usr/share/doc/crewship/licenses/frontend/npm-licenses.json COPY docker/server-entrypoint.sh /usr/local/bin/crewship-entrypoint RUN chmod +x /usr/local/bin/crewship-entrypoint ENV DATABASE_URL=file:/data/crewship.db \ CREWSHIP_STORAGE_BASE_PATH=/var/lib/crewship \ CREWSHIP_LOG_PATH=/var/log/crewship \ CREWSHIP_BOLT_PATH=/data/state.db WORKDIR /data USER crewship EXPOSE 8080 # The wrapper script pre-flight-checks required env vars (NEXTAUTH_SECRET, # ENCRYPTION_KEY) and prints an actionable error before the binary panics # deep inside server.New(). `docker run` without these vars now exits 78 # with copy-pasteable fix instructions instead of leaving the user with a # blank :8080 and a stack trace buried in `docker logs`. ENTRYPOINT ["crewship-entrypoint"]