# Docker Compose Local Environment -- TRD

## Status

Shipped (2026-03-17)

## Overview

Complete containerized local development environment for the ows-coda monorepo. Provides MySQL 8, Redis 7, S3-compatible object storage (RustFS), an automatic Prisma migration runner, an Express API server with hot-reload (tsx --watch), and a Vite dev server with HMR -- all orchestrated by Docker Compose profiles. Supports four operational modes: dev (full HMR stack), preview (production build with local infrastructure), production (CI/Fargate, no local infra), and testing (unit, functional, integration, E2E). A single `pnpm docker:up` command brings up the entire dev environment; no local MySQL, Redis, or S3 installation required.

## Goals

1. **One-command setup** -- `pnpm docker:up` gives a complete local dev environment with hot-reload for both server and client.
2. **Service isolation** -- Separate containers for database, cache, object storage, migrations, API server, and client, connected on a shared Docker network.
3. **Production preview** -- `pnpm docker:up:preview` builds the client and serves it from Express, matching the Fargate deployment topology.
4. **CI-compatible test runners** -- Dedicated test profiles (`test-unit`, `test-functional`, `test-integration`, `test-e2e`) with correct exit code propagation for pipeline integration.
5. **Team convention alignment** -- Follow patterns established by ows-royalties (root/app DB users, tmpfs, health check chains) while improving on them (native arm64, YAML anchors for DRY config).
6. **Zero infrastructure cost** -- All services run locally; no cloud resources consumed for development.

## Architecture

### Service Topology

```
+---------------------------------------------------------------+
|  Docker Compose Network (default bridge, project: coda)       |
|                                                                |
|  +----------+  +----------+  +---------+                       |
|  |  mysql    |  |  redis   |  |   s3    |   Always-on infra    |
|  |  :3306    |  |  :6379   |  | :9000/1 |   (profile-gated)   |
|  +-----+----+  +-----+----+  +----+----+                       |
|        |              |            |                            |
|  +-----v----+         |            |                            |
|  | migrate  |         |            |    One-shot Prisma runner  |
|  | (exit 0) |         |            |    + db:seed               |
|  +-----+----+         |            |                            |
|        |              |            |                            |
|  +-----v--------------v------------v-----+                     |
|  |            server                      |   Express API :8080 |
|  |  (tsx --watch + bind-mounts)           |   Host :8080        |
|  +------------------+--------------------+                     |
|                     |                                           |
|  +------------------v--------------------+                     |
|  |          client-dev                    |   Vite :6005        |
|  |  (HMR, proxies /api -> server)        |   Host :6005        |
|  +---------------------------------------+                     |
|                                                                |
|  -- profile: preview ----------------------------------------  |
|  +---------------------------------------+                     |
|  |           preview                      |   Built app :8080   |
|  |  (deploy-local target, host :6005)    |   Host :6005        |
|  +---------------------------------------+                     |
|                                                                |
|  -- profile: production (no local infra) --------------------  |
|  +---------------------------------------+                     |
|  |          production                    |   Built app :8080   |
|  |  (deploy-local target, host :8080)    |   Host :8080        |
|  +---------------------------------------+                     |
|                                                                |
|  -- profile: test-unit --------------------------------------  |
|  +---------------------------------------+                     |
|  |         lint-and-test                  |   Self-contained    |
|  |  (lint + typecheck + unit tests)      |   No infra deps     |
|  +---------------------------------------+                     |
|                                                                |
|  -- profile: test-functional --------------------------------  |
|  +---------------------------------------+                     |
|  |       test-functional                  |   Real DB + Redis   |
|  |  depends: migrate, redis              |                     |
|  +---------------------------------------+                     |
|                                                                |
|  -- profile: test-integration -------------------------------  |
|  +---------------------------------------+                     |
|  |       test-integration                 |   HTTP -> server    |
|  |  depends: server (healthy)            |                     |
|  +---------------------------------------+                     |
|                                                                |
|  -- profile: test-e2e ---------------------------------------  |
|  +---------------------------------------+                     |
|  |         test-e2e                       |   Browser -> deploy |
|  |  depends: preview (healthy)           |   (placeholder)     |
|  +---------------------------------------+                     |
+---------------------------------------------------------------+
```

### Profiles

| Profile            | Services started                                    | Use case                               |
| ------------------ | --------------------------------------------------- | -------------------------------------- |
| `dev`              | mysql, redis, s3, migrate, server, client-dev       | Local development with HMR             |
| `preview`          | mysql, redis, s3, migrate, preview                  | Production build against local infra   |
| `production`       | production (only)                                   | CI/Fargate -- no local infrastructure  |
| `test-unit`        | lint-and-test                                       | Lint, typecheck, unit tests            |
| `test-functional`  | mysql, redis, migrate, test-functional              | Functional tests against real DB       |
| `test-integration` | mysql, redis, s3, migrate, server, test-integration | HTTP tests against running server      |
| `test-e2e`         | mysql, redis, s3, migrate, preview, test-e2e        | Playwright browser tests (placeholder) |

**Profile exclusivity:** The `server`/`client-dev` (dev) and `preview`/`production` services share host port mappings. Running conflicting profiles simultaneously causes port collisions. Use one mode at a time.

**All services require an explicit `--profile` flag** -- `docker compose up` with no profile starts nothing. This is a deliberate design choice so that the default Compose invocation does not accidentally spin up infrastructure.

### Health Check Chain

```
1. mysql + redis + s3                    (parallel, health-checked)
2. migrate                              (waits: mysql healthy -> runs migrations + seed -> exits)
3. server                               (waits: migrate completed + redis healthy + s3 healthy)
4. client-dev                           (waits: server healthy)
```

Preview profile replaces steps 3-4 with `preview` (waits: migrate completed + redis healthy + s3 healthy).

### Network

All services communicate on the default Compose bridge network (project name `coda`). Service names (`mysql`, `redis`, `s3`, `server`, `preview`) serve as DNS hostnames within the network.

## Detailed Design

### docker-compose.yml Services

**YAML anchors** reduce duplication across services:

| Anchor              | Contents                                                                                | Used by                                            |
| ------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `x-db-env`          | `CODA_DB_*` connection vars (host=mysql, user=coda_app)                                 | server, preview, test-functional, test-integration |
| `x-aws-env`         | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_DEFAULT_REGION` | server, production, preview                        |
| `x-app-healthcheck` | Node.js HTTP health check on `:8080/health`                                             | server, production, preview                        |
| `x-build-common`    | Build context (`.`) + GITHUB_NPM_TOKEN secret                                           | All built services                                 |

**Infrastructure services:**

- **mysql** -- `mysql:8.0.36` image, tmpfs-backed (`/var/lib/mysql`, 1 GB), performance-tuned (`--disable-log-bin`, `--innodb_doublewrite=0`, `--innodb_flush_log_at_trx_commit=2`, `--sync_binlog=0`, `--skip-name-resolve`). Creates `root`/`root` (DDL) and `coda_app`/`coda_app_pass` (DML) users. Host port configurable via `MYSQL_HOST_PORT` env var (default 6789) for CI flexibility.
- **redis** -- `redis:7-alpine` image. Exposed on container port 6379 (not host-mapped; only container-to-container communication). Health checked via `redis-cli ping`.
- **s3** -- `rustfs/rustfs:1.0.0-alpha.85` (S3-compatible object storage). Ports 9000 (API) and 9001 (console) mapped to host. tmpfs-backed data and logs. Health checked via HTTP response on port 9000.
- **migrate** -- One-shot container (`dev-deps` -> `migrate` target). Runs `pnpm --filter @coda/db migrate:deploy && pnpm db:seed`, then exits. Uses `root`/`root` credentials for DDL operations. Depends on `mysql: service_healthy`.

**Dev services (profile: dev):**

- **server** -- Built from `dev` Dockerfile target. Command: `pnpm --filter @coda/server-app dev` (tsx --watch). Bind-mounts `server/src`, `api/src`, `common/src`, `db/src`, `db/prisma`, and `server/.env` for live-reload. Host port 8080. Depends on `migrate: service_completed_successfully`, `redis: service_healthy`, `s3: service_healthy`.
- **client-dev** -- Built from `client-dev` Dockerfile target. Runs Vite dev server on port 6005. Bind-mounts `client/src` for HMR. Sets `APP_URL=http://server:8080` for Vite proxy, `DOCKER=true` for polling/HMR config. Host port 6005. Depends on `server: service_healthy`.

**Preview service (profile: preview):**

- **preview** -- Built from `deploy-local` Dockerfile target with build args for Auth0/Sentry config. Builds client inside Docker, serves everything from Express on port 8080 (mapped to host 6005). Depends on full infrastructure stack.

**Production service (profile: production):**

- **production** -- Same `deploy-local` target, but no local infrastructure dependencies. Maps to host port 8080. Designed for CI/Fargate where Aurora, ElastiCache, and S3 are AWS-managed. Not for local use.

**Test services:**

- **lint-and-test** (profile: `test-unit`) -- `lint-and-test` target. Runs `pnpm lint && pnpm typecheck && pnpm test:unit --coverage`. No infrastructure dependencies.
- **test-functional** (profile: `test-functional`) -- `test-functional` target. Depends on `migrate` + `redis`. Runs `pnpm test:functional` against real MySQL and Redis.
- **test-integration** (profile: `test-integration`) -- `test-integration` target. Depends on `server: service_healthy`. Sends HTTP requests to the running server.
- **test-e2e** (profile: `test-e2e`) -- Placeholder. Uses `test-integration` target as stand-in until a dedicated Playwright Dockerfile target is created. Depends on `preview: service_healthy`.

### Dockerfile Targets

| Target             | Base                  | Purpose                             | Entrypoint                                                    |
| ------------------ | --------------------- | ----------------------------------- | ------------------------------------------------------------- |
| `prod-deps`        | ECR parent (`node24`) | Production dependencies only        | --                                                            |
| `dev-deps`         | `prod-deps`           | All dependencies + full source      | --                                                            |
| `dev`              | `dev-deps`            | Hot-reload dev server               | `pnpm --filter @coda/server-app dev`                          |
| `migrate`          | `dev-deps`            | One-shot migration + seed           | `pnpm --filter @coda/db migrate:deploy && pnpm db:seed`       |
| `client-dev`       | `dev-deps`            | Vite dev server with HMR            | `pnpm dev:client`                                             |
| `test-base`        | `dev-deps`            | Adds Jest/Vitest configs            | --                                                            |
| `test-functional`  | `test-base`           | Functional tests                    | `pnpm test:functional`                                        |
| `test-integration` | `test-base`           | Integration tests                   | `pnpm test:integration`                                       |
| `lint-and-test`    | `test-base`           | Lint + typecheck + unit tests       | `pnpm lint && pnpm typecheck && pnpm test:unit --coverage`    |
| `build-artifacts`  | `dev-deps`            | Server TypeScript build             | --                                                            |
| `client-build`     | `dev-deps`            | Client Vite build (local)           | --                                                            |
| `deploy-server`    | ECR parent            | Production server (no static files) | `node --import dd-trace/initialize.mjs server/dist/index.mjs` |
| `deploy-local`     | `deploy-server`       | Full stack for local preview        | (inherits)                                                    |
| `deploy`           | `deploy-server`       | Full stack for CI                   | (inherits)                                                    |

The `deploy-server` target includes a `HEALTHCHECK` instruction using Node.js HTTP (no curl/wget dependency on the ECR base image).

All pre-existing targets remain unchanged -- the CI pipeline is unaffected.

### Environment Variables

**Strategy:** Infrastructure credentials are hardcoded in Compose (safe for local-only use). Application secrets flow from existing `server/.env` and `client/.env` files. Compose `environment:` blocks override values from `env_file:` -- Docker-specific hostnames and ports always win over whatever is in the developer's `.env`.

**Individual `CODA_DB_*` vars** -- the project uses `CODA_DB_HOST`, `CODA_DB_PORT`, `CODA_DB_USER`, `CODA_DB_PASS`, `CODA_DB_DATABASE`, `CODA_DB_DRIVER` (not a single `DATABASE_URL`). Both the server config loader and Prisma's `prisma.config.ts` compose the connection URL from these parts.

**What developers must configure:**

| Item               | How                                                                                                            |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `server/.env`      | Copy from `server/.env.shadow`. Contains Auth0, Bedrock model config, identity crypto keys, Sentry, Snowflake. |
| `client/.env`      | Copy from `client/.env.shadow`. Contains Auth0 client ID/domain, Sentry DSN.                                   |
| AWS credentials    | `aws sso login` (sets `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` in host env)           |
| `GITHUB_NPM_TOKEN` | Set in shell profile (used by Docker builds for private npm packages)                                          |
| ECR login          | `pnpm docker:login` (authenticates Docker to pull the private ECR parent image)                                |
| Snowflake key      | Place at `~/.ssh/snowflake/rsa_key.p8` (bind-mounted into server/preview containers)                           |

**Identity crypto keys:** `CODA_DB_IDENTITY_HMAC_SECRET` and `CODA_DB_IDENTITY_AES_KEY` must be set in `server/.env` for DB persistence to work. Generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.

### Bind Mounts for HMR

**Server (tsx --watch):**

| Host path          | Container path                   | Purpose                                      |
| ------------------ | -------------------------------- | -------------------------------------------- |
| `./server/src`     | `/var/app/server/src`            | Server source hot-reload                     |
| `./api/src`        | `/var/app/api/src`               | Shared API types                             |
| `./common/src`     | `/var/app/common/src`            | Common package source                        |
| `./db/src`         | `/var/app/db/src`                | DB package source                            |
| `./db/prisma`      | `/var/app/db/prisma`             | Prisma schema + migrations                   |
| `./server/.env`    | `/var/app/server/.env` (ro)      | tsx `--env-file=.env` requires file presence |
| `~/.ssh/snowflake` | `/home/node/.ssh/snowflake` (ro) | Snowflake private key                        |

**Client (Vite HMR):**

| Host path      | Container path        | Purpose           |
| -------------- | --------------------- | ----------------- |
| `./client/src` | `/var/app/client/src` | Client source HMR |

**Vite config adaptations for Docker:**

- `server.watch.usePolling: true` when `DOCKER=true` -- Docker Desktop bind mounts can miss macOS filesystem events.
- `server.hmr.clientPort` set to `VITE_HMR_PORT` (6005) -- the HMR WebSocket must reach the browser at the host-mapped port.
- `server.host: true` when `DOCKER=true` -- listen on `0.0.0.0` so the container port is reachable.
- `import.meta.env.VITE_APP_URL` set to empty string when `DOCKER=true` -- the browser cannot resolve Docker-internal hostnames like `server:8080`, so the client uses relative `/api` paths that Vite proxies.

### tmpfs for MySQL

MySQL data directory is mounted as tmpfs (RAM-backed, 1 GB). Data is ephemeral -- migrations recreate the schema on every `docker compose up`. This eliminates disk I/O overhead and avoids stale data issues. The 1 GB size is sufficient for the Coda schema plus seed data.

### Test Profiles

Test commands in `package.json` use `docker compose run` (not `up`) for test profiles:

```
docker:test:unit        -> docker compose ... --profile test-unit run --build --rm lint-and-test
docker:test:functional  -> docker compose ... --profile test-functional run --build --rm test-functional
docker:test:integration -> docker compose ... --profile test-integration run --build --rm test-integration
docker:test:e2e         -> echo + exit 1 (not yet implemented)
```

`docker compose run` starts dependent services, runs the test container, and exits. The `--rm` flag removes the container after execution. Exit codes propagate correctly for CI.

### Database Users

| User       | Password        | Purpose                          | Used by                          |
| ---------- | --------------- | -------------------------------- | -------------------------------- |
| `root`     | `root`          | DDL (schema changes, migrations) | migrate container                |
| `coda_app` | `coda_app_pass` | DML (application queries)        | server, preview, test containers |

### Database Seeding

The `migrate` container runs both `prisma migrate deploy` and `pnpm db:seed` (reference data seeding). This ensures every fresh environment starts with a consistent baseline. Individual test suites can still seed additional data via `beforeAll` hooks for test-specific fixtures.

## Alternatives Explored

### Docker Compose vs. Kubernetes locally

Docker Compose was chosen for simplicity. Kubernetes (via minikube, kind, or Docker Desktop K8s) adds significant complexity (pod specs, services, ingress, persistent volume claims) with no benefit for local development. The production deployment target is ECS Fargate (not Kubernetes), so there is no parity argument for K8s locally.

### tmpfs vs. persistent Docker volumes for MySQL

tmpfs was chosen over named volumes because:

- **Speed** -- RAM-backed storage eliminates disk I/O entirely. Migration + seed completes faster.
- **Reproducibility** -- Every `docker compose up` starts from a clean schema. No stale data, no drift.
- **Simplicity** -- No volume cleanup commands needed. `docker compose down` discards everything.

The tradeoff is that data does not survive container restarts. This is acceptable because migrations recreate the schema in seconds, and the Coda schema is small.

### Separate test profiles vs. a single test container

Separate profiles (`test-unit`, `test-functional`, `test-integration`, `test-e2e`) were chosen over a single monolithic test container because:

- **Dependency isolation** -- Unit tests need zero infrastructure. Functional tests need MySQL + Redis. Integration tests need a running server. Each profile brings only what it needs.
- **CI parallelism** -- CI pipelines can run different test suites in parallel with different infrastructure requirements.
- **Exit code clarity** -- Each test runner has a single exit code. A monolithic container would need complex scripting to report partial failures.

### Why no `platform: linux/amd64`

The ows-royalties Docker setup uses `platform: linux/amd64` for MySQL. We deliberately omit this because `mysql:8.0.36` publishes native arm64 images. On Apple Silicon Macs, this avoids Rosetta/QEMU x86 emulation overhead, resulting in significantly faster MySQL startup and query execution.

### Health check implementation: Node.js HTTP vs. curl/wget

The `deploy-server` Dockerfile and compose `x-app-healthcheck` anchor use an inline Node.js script (`node -e "require('http').get(...)"`) rather than curl or wget. This avoids a dependency on tools that may not be present in the private ECR parent image, since Node.js is guaranteed to exist.

### `docker compose run` vs. `docker compose up` for tests

Test scripts use `docker compose run --build --rm <service>` rather than `docker compose up --abort-on-container-exit --exit-code-from <service>`. The `run` approach is cleaner: it starts dependencies, runs exactly one container, and returns its exit code directly without needing to stop all containers.

### Port 6005 vs. 5173 for Vite

The shipped implementation maps the Vite dev server to host port 6005 (not the Vite default 5173 or the original design's 5173). Port 6005 was chosen to be consistent with the preview profile, which also maps to host port 6005. This gives developers a single URL (`localhost:6005`) regardless of whether they are running dev or preview mode.

### Profile-gated infrastructure vs. always-on

All services, including infrastructure (MySQL, Redis, S3), require an explicit `--profile` flag. This prevents accidental resource consumption from a bare `docker compose up` and ensures developers are intentional about which mode they are running.

## Cost Analysis

### Infrastructure cost

Zero. All services run locally on the developer's machine. No cloud resources are provisioned for the Docker Compose environment.

### Engineering effort

- **Design + implementation:** ~2 days (spec, Dockerfile targets, compose file, Vite config updates, documentation)
- **Testing + iteration:** ~1 day (smoke tests across all profiles, HMR verification, CI compatibility)

### Developer time saved

- **Environment setup:** Previously required local MySQL + Redis installation, manual configuration, and troubleshooting OS-specific issues. Now: `pnpm docker:up` (first run ~2 min for image build, subsequent runs ~15 sec).
- **Onboarding:** New team members get a working environment in minutes instead of hours.
- **Test infrastructure:** Functional and integration tests can be run with a single command, matching what CI runs.
- **Production preview:** Developers can verify production build behavior locally before pushing.

## Performance Analysis

### Startup time

| Phase                  | Typical time | Notes                                                                           |
| ---------------------- | ------------ | ------------------------------------------------------------------------------- |
| Image build (cold)     | 60-120s      | Includes pnpm install, Prisma generate. Cached layers reduce subsequent builds. |
| Image build (warm)     | 5-15s        | Only changed layers rebuild.                                                    |
| MySQL ready            | 5-10s        | tmpfs eliminates disk init. Health check polls every 5s.                        |
| Redis ready            | 1-2s         | Alpine image, minimal startup.                                                  |
| S3 (RustFS) ready      | 2-5s         | tmpfs-backed.                                                                   |
| Migrations             | 3-8s         | Prisma migrate deploy + seed on empty tmpfs database.                           |
| Server ready           | 5-10s        | tsx compiles TypeScript on-the-fly.                                             |
| Client-dev ready       | 3-5s         | Vite dev server with dependency pre-bundling.                                   |
| **Total (warm build)** | **~30-45s**  | From `docker compose up` to browsable at localhost:6005.                        |

### HMR latency

- **Server (tsx --watch):** File change to server restart: ~1-2s. tsx watches bind-mounted source files and re-executes on change.
- **Client (Vite HMR):** File change to browser update: ~100-300ms. Vite's native ESM HMR is near-instant. Polling mode (`usePolling: true`) adds negligible overhead.

### MySQL tmpfs vs. disk performance

tmpfs eliminates all disk I/O for MySQL data files. On macOS with Docker Desktop, this also avoids the VirtioFS/osxfs overhead that makes bind-mounted MySQL data directories extremely slow. Typical improvement: 5-10x faster for write-heavy operations (migrations, seeding, test data setup).

## Scaling Characteristics

This environment is designed exclusively for local development and CI -- not production scaling.

- **Profile system scales to new test types:** Adding a new test category (e.g., `test-performance`, `test-smoke`) requires only a new Dockerfile target, a compose service with the appropriate `profiles:` key, and a `package.json` script. The pattern is fully established.
- **Service count:** The current 12 services (4 infrastructure + 3 dev + 1 production + 1 preview + 3 test) are manageable. Docker Compose handles this without performance issues.
- **Resource consumption:** With all dev services running, typical resource usage is ~1.5 GB RAM (MySQL tmpfs: 1 GB max, Redis: ~50 MB, server: ~200 MB, client-dev: ~200 MB, RustFS: minimal).

## Breakdown Points and Mitigations

### Docker resource limits

**Risk:** Docker Desktop default memory limit (8 GB) may be insufficient if the developer is running other containers.

**Mitigation:** MySQL tmpfs is capped at 1 GB, and the total dev stack uses ~1.5 GB. Document minimum Docker Desktop resource requirements (4 GB RAM allocated to Docker).

### Port conflicts

**Risk:** Host ports 6005, 8080, 6789, 9000, 9001 may conflict with other services.

**Mitigation:** `pnpm docker:down` stops all containers. `lsof -i :<port>` identifies conflicts. MySQL host port is configurable via `MYSQL_HOST_PORT` env var (CI sets it to `0` for random port assignment).

### Stale volumes and images

**Risk:** After significant Dockerfile or dependency changes, cached layers may produce broken builds.

**Mitigation:** `pnpm docker:clean` removes all images, volumes, and orphans. The `--build` flag on `docker:up` ensures images are rebuilt on every invocation.

### Architecture mismatches

**Risk:** MySQL and other images may behave differently on arm64 (Apple Silicon) vs. amd64 (Intel/CI).

**Mitigation:** `mysql:8.0.36` has native arm64 support. RustFS and Redis Alpine images also support multi-arch. No `platform: linux/amd64` is forced, avoiding Rosetta emulation. CI runs on amd64 natively.

### ECR authentication expiry

**Risk:** Docker builds fail if ECR login has expired (private parent image pull fails).

**Mitigation:** `pnpm docker:login` re-authenticates. Error message is clear ("denied: Your authorization token has expired"). Documented in prerequisites.

### AWS credential expiry

**Risk:** Server fails to call Bedrock if AWS SSO session has expired.

**Mitigation:** AWS credentials are passed through from the host environment (`${AWS_ACCESS_KEY_ID:-}`). Re-run `aws sso login` and restart the server container. The server starts even without valid AWS creds -- Bedrock calls fail gracefully at runtime.

### bind-mount filesystem event misses

**Risk:** macOS Docker Desktop may miss filesystem events on bind-mounted volumes, causing HMR to stop working.

**Mitigation:** Vite uses `usePolling: true` when `DOCKER=true`. tsx --watch handles this natively. If issues persist, restart the affected container.

## Decision Log

| Date       | Decision                                      | Rationale                                                                                       |
| ---------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| 2026-03-17 | Use Docker Compose (not K8s) for local dev    | Production target is ECS Fargate, not K8s. Compose is simpler and sufficient.                   |
| 2026-03-17 | tmpfs for MySQL data                          | Speed + reproducibility. No stale data. Migrations are fast enough to run on every startup.     |
| 2026-03-17 | Separate test profiles                        | Dependency isolation, CI parallelism, clean exit codes.                                         |
| 2026-03-17 | Omit `platform: linux/amd64`                  | mysql:8.0.36 has native arm64. Avoids Rosetta overhead on Apple Silicon.                        |
| 2026-03-17 | Root user for migrations, app user for server | Follows ows-royalties convention. Principle of least privilege for the application.             |
| 2026-03-17 | Health checks use Node.js HTTP, not curl/wget | No dependency on tools that may not exist in the ECR parent image.                              |
| 2026-03-17 | Hardcode infra credentials in Compose         | Local-only environment. Eliminates configuration burden. No security risk.                      |
| 2026-03-17 | Compose `environment:` overrides `env_file:`  | Docker-specific hosts/ports (mysql, redis) always win over developer `.env` values.             |
| 2026-03-17 | Port 6005 for client dev and preview          | Single URL for developers regardless of mode. Avoids collision with server port 8080.           |
| 2026-03-17 | All services behind explicit profiles         | Prevents accidental resource consumption. Developer must be intentional.                        |
| 2026-03-17 | `docker compose run` for tests (not `up`)     | Cleaner exit code propagation. Starts deps, runs one container, returns exit code.              |
| 2026-03-17 | Include db:seed in migrate container          | Every fresh environment gets reference data automatically. Tests add their own fixtures on top. |
| 2026-03-17 | Add S3-compatible object storage (RustFS)     | Server requires S3 for file storage. RustFS is lightweight, S3-compatible, and tmpfs-backed.    |

## Dependencies

### Runtime Dependencies

| Dependency            | Version        | Purpose                                                      |
| --------------------- | -------------- | ------------------------------------------------------------ |
| Docker Desktop        | Latest         | Container runtime                                            |
| `mysql` image         | 8.0.36         | Relational database (matches Aurora MySQL 8.0 in production) |
| `redis` image         | 7-alpine       | In-memory cache (matches ElastiCache Redis 7 in production)  |
| `rustfs/rustfs` image | 1.0.0-alpha.85 | S3-compatible object storage                                 |
| ECR parent image      | `node24` tag   | Private base image with Node.js 24                           |

### Build Dependencies

| Dependency          | Purpose                                                       |
| ------------------- | ------------------------------------------------------------- |
| `GITHUB_NPM_TOKEN`  | Authenticates to GitHub Packages for private npm dependencies |
| AWS ECR credentials | Pulls the private parent image                                |

### Host Dependencies

| Dependency                    | Purpose                                                                                |
| ----------------------------- | -------------------------------------------------------------------------------------- |
| AWS SSO session               | Provides `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` for Bedrock |
| `server/.env`                 | Auth0, Bedrock model config, identity crypto keys, Sentry, Snowflake                   |
| `client/.env`                 | Auth0 client ID/domain, Sentry DSN                                                     |
| `~/.ssh/snowflake/rsa_key.p8` | Snowflake private key (bind-mounted)                                                   |

## Testing Strategy

### In Docker (CI-style)

```bash
pnpm docker:test:unit           # lint + typecheck + unit tests (no infra)
pnpm docker:test:functional     # functional tests with real MySQL + Redis
pnpm docker:test:integration    # HTTP tests against running server
pnpm docker:test:e2e            # not yet implemented (needs Playwright target)
```

Each test profile starts only the infrastructure it needs. Exit codes propagate to the calling process for CI pipeline integration.

### From Host (faster iteration)

Developers can run targeted tests from the host while Docker provides infrastructure:

```bash
# Unit tests -- no Docker needed
pnpm test:unit
pnpm --filter @coda/server-app test:unit

# Functional tests -- start infra first
docker compose --project-name coda --profile test-functional up -d mysql redis migrate
pnpm --filter @coda/server-app test:functional

# Integration tests -- start backend stack first
docker compose --project-name coda --profile test-integration up -d mysql redis migrate server
pnpm --filter @coda/server-app test:integration

# E2E tests -- start full preview stack first
docker compose --project-name coda --profile preview up --build -d
pnpm docker:test:e2e
```

### Override test command in Docker

```bash
docker compose --project-name coda --profile test-unit \
  run lint-and-test pnpm --filter @coda/server-app test:unit
```

### Smoke Tests (completed at ship)

- Dev profile: all default services start, client loads at localhost:6005, server responds at localhost:8080/health, MySQL accessible at localhost:6789, server HMR works, client HMR works.
- Preview profile: full stack at localhost:6005 with production build.
- Test-unit profile: exits with correct exit code.
- Test-functional profile: exits with correct exit code.
- Test-integration profile: exits with correct exit code.

## Rollout Plan

This feature shipped on 2026-03-17. Rollout was straightforward:

1. **Merge to master** -- All changes (Dockerfile targets, docker-compose.yml, Vite config, package.json scripts, documentation) shipped in a single PR.
2. **Team communication** -- Posted in team Slack channel with link to `docs/guides/docker.md` for quick-start instructions.
3. **No migration required** -- The Docker environment is additive. Developers who prefer running services on the host can continue to do so. All existing host-based scripts (`pnpm dev`, `pnpm test:unit`, etc.) remain unchanged.
4. **Backward compatibility** -- All pre-existing Dockerfile targets and CI pipeline configurations are untouched.

## Open Questions

1. **E2E test target** -- The `test-e2e` profile is a placeholder. A dedicated Dockerfile target with Playwright and browser dependencies (Chromium) is needed before E2E tests can run in Docker. The `docker:test:e2e` script currently prints a message and exits with code 1.
2. **S3 bucket provisioning** -- The RustFS container starts empty. Bucket creation is not automated in the compose file. Tests and the server may need an init script or startup hook to create required buckets.
3. **Redis host port** -- Redis is `expose`-only (container-to-container), not mapped to a host port. If developers need direct Redis access for debugging, a host port mapping should be added.
