Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.env
.env.*
!.env.example

node_modules
**/node_modules
**/.next
**/.turbo
**/.eve
**/dist
**/.output
.vercel
**/.vercel

.git
.claude
docs/images
adrs
73 changes: 73 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -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"
63 changes: 63 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
32 changes: 32 additions & 0 deletions ISSUES_AASP.md
Original file line number Diff line number Diff line change
@@ -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`.
34 changes: 34 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/crm-api` | 3001 | `api` |
| `ghcr.io/<owner>/crm-app` | 3000 | `app` |
| `ghcr.io/<owner>/crm-agent` | 2000 | `agent` |

Each image gets three tags: `latest`, the root `package.json` version, and
`sha-<commit>`.

```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
Expand Down