From 09f93990545bafacebdf0fdfdfd3eaf7324b0d66 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Wed, 23 Sep 2026 10:56:07 +0200 Subject: [PATCH 1/2] feat: publish api, app and agent Docker images from release --- .dockerignore | 18 +++++++++ .github/workflows/docker.yml | 73 ++++++++++++++++++++++++++++++++++++ Dockerfile | 63 +++++++++++++++++++++++++++++++ docs/setup.md | 34 +++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d94c7afaa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.env +.env.* +!.env.example + +node_modules +**/node_modules +**/.next +**/.turbo +**/.eve +**/dist +**/.output +.vercel +**/.vercel + +.git +.claude +docs/images +adrs diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..c6ab897fb --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,73 @@ +name: Docker + +on: + push: + branches: [release] + workflow_dispatch: + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + packages: write + +jobs: + image: + name: Publish ${{ matrix.target }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + target: [api, app, agent] + + steps: + - uses: actions/checkout@v5 + + - name: Read the version + id: version + run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/${{ github.repository_owner }}/crm-${{ matrix.target }} + tags: | + type=raw,value=latest + type=raw,value=${{ steps.version.outputs.version }} + type=sha + + - uses: docker/build-push-action@v6 + with: + context: . + target: ${{ matrix.target }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL || 'http://localhost:3001' }} + cache-from: type=gha,scope=${{ matrix.target }} + cache-to: type=gha,scope=${{ matrix.target }},mode=max + + - name: Say what was published + env: + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + { + echo "Published \`${{ matrix.target }}\`:" + echo + echo '```' + echo "$TAGS" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..061a76b75 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1 + +ARG BUN_VERSION=1.3.12 +ARG NODE_VERSION=22 + +FROM node:${NODE_VERSION}-bookworm-slim AS node + +FROM oven/bun:${BUN_VERSION} AS source +WORKDIR /repo +ENV TURBO_TELEMETRY_DISABLED=1 +ENV DATABASE_URL=postgresql://build:build@localhost:5432/build +COPY . . +RUN --mount=type=cache,target=/root/.bun/install/cache \ + bun install --frozen-lockfile +ENV NODE_ENV=production + +FROM source AS api-build +RUN --mount=type=cache,target=/root/.bun/install/cache \ + bunx turbo run build --filter=api \ + && rm -rf node_modules apps/*/node_modules packages/*/node_modules \ + && bun install --frozen-lockfile --ignore-scripts --filter=api --filter=@crm/db + +FROM source AS app-build +COPY --from=node /usr/local/bin/node /usr/local/bin/node +ARG NEXT_PUBLIC_API_URL=http://localhost:3001 +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +RUN bunx turbo run build --filter=app \ + && rm -rf apps/app/.next/cache + +FROM source AS agent-build +RUN --mount=type=cache,target=/root/.bun/install/cache \ + bunx turbo run build --filter=agent \ + && rm -rf node_modules apps/*/node_modules packages/*/node_modules \ + && bun install --frozen-lockfile --ignore-scripts --filter=agent + +FROM oven/bun:${BUN_VERSION}-slim AS runtime +WORKDIR /repo +ENV NODE_ENV=production + +FROM runtime AS api +COPY --from=api-build --chown=bun:bun /repo /repo +USER bun +ENV PORT=3001 +EXPOSE 3001 +WORKDIR /repo/apps/api +CMD ["sh", "-c", "cd /repo/packages/db && bun run db:deploy && cd /repo/apps/api && exec bun run start:prod"] + +FROM runtime AS app +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=app-build --chown=bun:bun /repo /repo +USER bun +ENV PORT=3000 +EXPOSE 3000 +WORKDIR /repo/apps/app +CMD ["bun", "run", "start"] + +FROM runtime AS agent +COPY --from=agent-build --chown=bun:bun /repo /repo +USER bun +ENV PORT=2000 +EXPOSE 2000 +WORKDIR /repo/apps/agent +CMD ["bun", "run", "start"] diff --git a/docs/setup.md b/docs/setup.md index 03b8912f7..b9987147c 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -135,6 +135,40 @@ DATABASE_URL="…" bunx prisma migrate diff \ --from-config-datasource --to-schema prisma/schema.prisma --script ``` +## Docker images + +Every push to `release` publishes three images to GitHub Container Registry, from +the one root `Dockerfile`. `.github/workflows/docker.yml` builds them. + +| Image | Port | Build target | +| --- | --- | --- | +| `ghcr.io//crm-api` | 3001 | `api` | +| `ghcr.io//crm-app` | 3000 | `app` | +| `ghcr.io//crm-agent` | 2000 | `agent` | + +Each image gets three tags: `latest`, the root `package.json` version, and +`sha-`. + +```sh +docker build --target api -t crm-api . +docker build --target app --build-arg NEXT_PUBLIC_API_URL=https://api.example.com -t crm-app . +docker build --target agent -t crm-agent . +``` + +- **The API URL is fixed when the app image is built.** `next.config.ts` inlines + `NEXT_PUBLIC_API_URL` into the server and the browser bundle. `API_URL` on the + app container does not move its requests. The workflow reads the repository variable + `NEXT_PUBLIC_API_URL` and falls back to `http://localhost:3001`. A different + API host needs its own app image. +- **The api container applies migrations when it starts**, with + `prisma migrate deploy`, before it listens. Start the api before the agent and + the app. Otherwise they briefly read tables that do not exist yet. +- **Never publish the agent port to the internet.** `localDev()` in + `agent/channels/eve.ts` accepts any request whose `Host` is `localhost`. + Keep the agent on a private network, reachable only from the app and the api. +- Runtime configuration is the same as everywhere else: the variables in + `.env.example`, passed with `-e` or `--env-file`. The images hold no `.env`. + ## Secrets hygiene `.gitignore` ignores `.env` and `.env.*` with one negation for `.env.example`, so From 36261b222901cab32345005f13ff2fa8a375325f Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Wed, 23 Sep 2026 11:30:43 +0200 Subject: [PATCH 2/2] docs: list open issues for the Docker images --- ISSUES_AASP.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 ISSUES_AASP.md diff --git a/ISSUES_AASP.md b/ISSUES_AASP.md new file mode 100644 index 000000000..351b05ba4 --- /dev/null +++ b/ISSUES_AASP.md @@ -0,0 +1,32 @@ +# Docker images — open issues + +Results with the published images: + +| Check | Result | +| --- | --- | +| Migrations applied at api start | 56 | +| api `/api/auth/ok` | 200 | +| app `/` | 307 → `/sign-in` | +| app `/sign-in` | 200 | +| app → api | 200 | +| app → agent | 401 (correct: no credentials) | +| Errors in the logs | 0 | + +## Issues + +1. RISK — Anyone can use the agent without auth by sending `Host: localhost`. `localDev()` in `apps/agent/agent/channels/eve.ts` trusts that header. A public agent port exposes the agent. + Fix: not done. Use `localDev()` only outside production. +2. RISK — The API address is fixed when the app image is built. The published app image calls `http://localhost:3001` and fails anywhere else. + Fix: set the repository variable `NEXT_PUBLIC_API_URL`. Choosing the address at start time is not done. +3. RISK — The Release workflow puts each release on `release` with `GITHUB_TOKEN`. Changes that token makes do not start other workflows, so the Docker workflow does not run. + Fix: set the `AUTOMATION_TOKEN` secret, or start the Docker workflow by hand in the Actions tab. +4. RISK — The agent and the app start before the api finishes the migrations. Their first database queries fail. + Fix: documented in `docs/setup.md`: start the api first. No health check makes them wait. +5. RISK — The app image is large: 4.7 GB unpacked. It keeps all packages, because Next.js links to exact package paths from the build. + Fix: not done. Next.js `output: "standalone"` makes it smaller, but it needs a change to `next.config.ts`. +6. NOT DONE — The workflow builds only `linux/amd64`. There are no ARM images. +7. NOT DONE — There is no pull request. The fork has no `main` branch. The commit has no Median task ID, because the repo has no `.median/config.json`. +8. UNKNOWN — The GHCR package visibility (public or private) is not confirmed. The gh token does not have the `read:packages` scope. +9. UNKNOWN — A real sign-in and an agent research run are not tested. The test had no Google credentials and no model key. +10. BROKEN — `git push` over HTTPS fails for workflow files. The gh token does not have the `workflow` scope. + Fix: push over SSH, or run `gh auth refresh -h github.com -s workflow`.