Skip to content

Commit 90f5b00

Browse files
authored
fix(docker): ship the realtime and migrations images with runtime dependencies only (#9983)
Bun's --omit=dev skips the root's devDependencies but still installs every workspace member, each member's devDependencies, and the optional peers the lockfile can satisfy. Both images therefore carried drizzle-kit (with its bundled esbuild binaries), vitest, vite and tsx, and the esbuild Go binaries account for nearly all of their HIGH/CRITICAL scanner findings. scripts/prune-production-workspace.ts narrows a turbo prune to the members the target reaches through dependencies, optionalDependencies or a tsconfig extends, and strips devDependencies from the root and the kept members. The migrations image gains a dev stage that keeps drizzle-kit for the dev environment's db:push; its final stage, which every other environment builds, runs db:migrate with runtime dependencies only.
1 parent e09144a commit 90f5b00

5 files changed

Lines changed: 192 additions & 43 deletions

File tree

‎.github/actions/image/action.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ inputs:
2323
description: Newline-separated Docker build arguments.
2424
required: false
2525
default: ''
26+
target:
27+
description: Dockerfile stage to build. Empty builds the final stage.
28+
required: false
29+
default: ''
2630
max-cache-size-mb:
2731
description: >-
2832
Layer cache to retain after this action prunes, in MB. Must stay above one
@@ -77,6 +81,7 @@ runs:
7781
push: true
7882
tags: ${{ inputs.tags }}
7983
build-args: ${{ inputs.build-args }}
84+
target: ${{ inputs.target }}
8085
provenance: false
8186
sbom: false
8287

@@ -183,5 +188,6 @@ runs:
183188
push: true
184189
tags: ${{ inputs.tags }}
185190
build-args: ${{ inputs.build-args }}
191+
target: ${{ inputs.target }}
186192
provenance: false
187193
sbom: false

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,11 @@ jobs:
209209
ecr_repo_secret: ECR_APP
210210
gh_runner: linux-x64-8-core
211211
bs_runner: blacksmith-16vcpu-ubuntu-2404
212+
# Dev runs `db:push`, which needs drizzle-kit; every other environment
213+
# gets the final stage, which carries runtime dependencies only.
212214
- id: db
213215
dockerfile: ./docker/db.Dockerfile
216+
target: dev
214217
ecr_repo_secret: ECR_MIGRATIONS
215218
gh_runner: ubuntu-latest
216219
bs_runner: blacksmith-2vcpu-ubuntu-2404
@@ -259,6 +262,7 @@ jobs:
259262
tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:${{ github.sha }}-dev
260263
build-args: |
261264
MSHIP_PLAN_MODE_DEFAULT=true
265+
target: ${{ matrix.target }}
262266
max-cache-size-mb: ${{ matrix.cache_mb }}
263267

264268
# Staging/production coordinate task releases with app traffic cutover.

‎docker/db.Dockerfile‎

Lines changed: 46 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,62 +4,68 @@
44
FROM oven/bun:1.4.4-alpine AS base
55

66
# ========================================
7-
# Dependencies Stage: Install Dependencies
7+
# Pruner Stage: Emit the monorepo subset @sim/db needs
88
# ========================================
9-
FROM base AS deps
9+
FROM base AS pruner
1010
WORKDIR /app
1111

12-
# Copy only package files needed for migrations (these change less frequently)
13-
COPY package.json bun.lock turbo.json ./
14-
COPY patches ./patches
15-
RUN mkdir -p packages/db packages/logger packages/security packages/tsconfig packages/utils
16-
COPY packages/db/package.json ./packages/db/package.json
17-
COPY packages/logger/package.json ./packages/logger/package.json
18-
COPY packages/security/package.json ./packages/security/package.json
19-
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
20-
COPY packages/utils/package.json ./packages/utils/package.json
21-
22-
# Install dependencies with cache mount for faster builds. This stage contains
23-
# only the migration workspace manifests, so Bun must normalize the full root
24-
# lockfile to that subset; full-repository CI owns frozen-lockfile validation.
25-
RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \
26-
bun install --ignore-scripts
12+
COPY . .
13+
14+
# out/ keeps @sim/db's development toolchain for the dev target's `db:push`;
15+
# production/ narrows a copy to what `db:migrate` runs (see scripts/prune-production-workspace.ts).
16+
RUN TURBO_VERSION="$(bun -e "console.log(require('./package.json').devDependencies.turbo)")" && \
17+
bunx --bun "turbo@${TURBO_VERSION}" prune @sim/db --docker && \
18+
cp -R out production && \
19+
bun scripts/prune-production-workspace.ts @sim/db production/full production/json
2720

2821
# ========================================
29-
# Runner Stage: Production Environment
22+
# Dependencies Stages
3023
# ========================================
31-
FROM base AS runner
24+
# Neither install is --frozen-lockfile: each holds a subset of the workspace, so
25+
# Bun normalizes the pruned lockfile to it while keeping every locked resolution.
26+
# Full-repository CI owns frozen-lockfile validation.
27+
FROM base AS dev-deps
3228
WORKDIR /app
3329

34-
# Create non-root user and group (cached separately)
35-
RUN addgroup -g 1001 -S nodejs && \
36-
adduser -S nextjs -u 1001
37-
38-
# Copy only the necessary files from deps (cached if dependencies don't change)
39-
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
30+
COPY --from=pruner /app/out/json/ ./
31+
COPY --from=pruner /app/out/bun.lock ./bun.lock
4032

41-
# Copy root package.json for workspace resolution
42-
COPY --chown=nextjs:nodejs package.json ./package.json
33+
RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \
34+
bun install --linker=hoisted --ignore-scripts
4335

44-
# Copy package configuration files (needed for migrations)
45-
COPY --chown=nextjs:nodejs packages/db/drizzle.config.ts ./packages/db/drizzle.config.ts
36+
FROM base AS deps
37+
WORKDIR /app
4638

47-
# Copy tsconfig package (needed for workspace symlink resolution)
48-
COPY --chown=nextjs:nodejs packages/tsconfig ./packages/tsconfig
39+
COPY --from=pruner /app/production/json/ ./
40+
COPY --from=pruner /app/production/bun.lock ./bun.lock
4941

50-
# Copy utils package (needed by db scripts that import @sim/utils)
51-
COPY --chown=nextjs:nodejs packages/utils ./packages/utils
42+
RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \
43+
bun install --linker=hoisted --omit=dev --ignore-scripts
5244

53-
# Copy logger package (needed by @sim/db's tx-tripwire at import time)
54-
COPY --chown=nextjs:nodejs packages/logger ./packages/logger
45+
FROM base AS user
46+
RUN addgroup -g 1001 -S nodejs && \
47+
adduser -S nextjs -u 1001
5548

56-
# Copy security package (the schema's sealed columns import its encryption)
57-
COPY --chown=nextjs:nodejs packages/security ./packages/security
49+
# ========================================
50+
# Dev Stage: also runs `db:push`, which needs drizzle-kit
51+
# ========================================
52+
FROM user AS dev
53+
WORKDIR /app
5854

59-
# Copy database package source code (changes most frequently - placed last)
60-
COPY --chown=nextjs:nodejs packages/db ./packages/db
55+
COPY --from=dev-deps --chown=nextjs:nodejs /app ./
56+
COPY --from=pruner --chown=nextjs:nodejs /app/out/full/ ./
6157

62-
# Switch to non-root user
6358
USER nextjs
59+
WORKDIR /app/packages/db
6460

61+
# ========================================
62+
# Runner Stage (default): runs `db:migrate` with runtime dependencies only
63+
# ========================================
64+
FROM user AS runner
65+
WORKDIR /app
66+
67+
COPY --from=deps --chown=nextjs:nodejs /app ./
68+
COPY --from=pruner --chown=nextjs:nodejs /app/production/full/ ./
69+
70+
USER nextjs
6571
WORKDIR /app/packages/db

‎docker/realtime.Dockerfile‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@ FROM oven/bun:1.4.4-alpine AS base
66
RUN apk add --no-cache libc6-compat curl
77

88
# ========================================
9-
# Pruner Stage: Emit a minimal monorepo subset that @sim/realtime depends on
9+
# Pruner Stage: Emit the monorepo subset @sim/realtime runs in production
1010
# ========================================
1111
FROM base AS pruner
1212
WORKDIR /app
1313

1414
COPY . .
1515

16+
# turbo keeps the workspace members @sim/realtime needs to build and test too;
17+
# scripts/prune-production-workspace.ts drops those and their devDependencies.
1618
RUN TURBO_VERSION="$(bun -e "console.log(require('./package.json').devDependencies.turbo)")" && \
17-
bunx --bun "turbo@${TURBO_VERSION}" prune @sim/realtime --docker
19+
bunx --bun "turbo@${TURBO_VERSION}" prune @sim/realtime --docker && \
20+
bun scripts/prune-production-workspace.ts @sim/realtime out/full out/json
1821

1922
# ========================================
2023
# Dependencies Stage: Install Dependencies
@@ -25,8 +28,11 @@ WORKDIR /app
2528
COPY --from=pruner /app/out/json/ ./
2629
COPY --from=pruner /app/out/bun.lock ./bun.lock
2730

31+
# Not --frozen-lockfile: the narrowed manifests no longer match the pruned
32+
# lockfile. Bun keeps every locked resolution, and full-repository CI owns
33+
# frozen-lockfile validation.
2834
RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \
29-
bun install --frozen-lockfile --linker=hoisted --omit=dev --ignore-scripts
35+
bun install --linker=hoisted --omit=dev --ignore-scripts
3036

3137
# ========================================
3238
# Runner Stage: Run the Socket Server
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Narrows a `turbo prune --docker` output to what one package runs in production, so a
3+
* following `bun install --omit=dev` installs only its runtime dependencies.
4+
*
5+
* `--omit=dev` leaves out the root's devDependencies, but Bun still installs every workspace
6+
* member, each member's devDependencies, and their peers. A pruned image therefore carried the
7+
* build and test toolchain (drizzle-kit and its bundled esbuild, vitest, vite, tsx) that its
8+
* members declare for development, along with members such as `@sim/testing` that only tests
9+
* import. This keeps the members the target reaches through `dependencies`,
10+
* `optionalDependencies`, or a `tsconfig.json` `extends` (Bun reads each member's tsconfig when it
11+
* runs TypeScript source), drops every other member, and strips the devDependencies of the root
12+
* and the kept members.
13+
*
14+
* Usage: `bun scripts/prune-production-workspace.ts <package> <full-dir> [<json-dir>]`. The closure is
15+
* computed from `<full-dir>`, which holds the tsconfig files, and applied to every directory given.
16+
* The lockfile no longer matches the narrowed manifests, so install without `--frozen-lockfile`:
17+
* Bun keeps each locked resolution whose range still matches and only drops the entries the
18+
* narrowed workspace no longer reaches, so no version changes.
19+
*/
20+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
21+
import { dirname, join } from 'node:path'
22+
23+
interface PackageManifest {
24+
name?: string
25+
workspaces?: string[]
26+
dependencies?: Record<string, string>
27+
optionalDependencies?: Record<string, string>
28+
devDependencies?: Record<string, string>
29+
}
30+
31+
interface WorkspaceMember {
32+
/** Member directory relative to the workspace root, e.g. `packages/db`. */
33+
dir: string
34+
manifest: PackageManifest
35+
}
36+
37+
function readJson<T>(path: string): T {
38+
return JSON.parse(readFileSync(path, 'utf8')) as T
39+
}
40+
41+
function listMembers(rootDir: string): Map<string, WorkspaceMember> {
42+
const root = readJson<PackageManifest>(join(rootDir, 'package.json'))
43+
const members = new Map<string, WorkspaceMember>()
44+
for (const pattern of root.workspaces ?? []) {
45+
for (const manifestPath of new Bun.Glob(`${pattern}/package.json`).scanSync(rootDir)) {
46+
const manifest = readJson<PackageManifest>(join(rootDir, manifestPath))
47+
if (manifest.name) members.set(manifest.name, { dir: dirname(manifestPath), manifest })
48+
}
49+
}
50+
return members
51+
}
52+
53+
/** The package a module specifier names, e.g. `@sim/tsconfig` for `@sim/tsconfig/base.json`. */
54+
function packageName(specifier: string): string {
55+
const segments = specifier.split('/')
56+
return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
57+
}
58+
59+
function tsconfigParents(rootDir: string, member: WorkspaceMember): string[] {
60+
const tsconfigPath = join(rootDir, member.dir, 'tsconfig.json')
61+
if (!existsSync(tsconfigPath)) return []
62+
const { extends: parents } = readJson<{ extends?: string | string[] }>(tsconfigPath)
63+
return [parents ?? []].flat().map(packageName)
64+
}
65+
66+
function productionClosure(
67+
rootDir: string,
68+
members: Map<string, WorkspaceMember>,
69+
target: string
70+
): Set<string> {
71+
const kept = new Set<string>()
72+
const queue = [target]
73+
for (let name = queue.pop(); name; name = queue.pop()) {
74+
if (kept.has(name)) continue
75+
const member = members.get(name)
76+
if (!member) throw new Error(`${name} is not a member of the workspace in ${rootDir}`)
77+
kept.add(name)
78+
const { dependencies = {}, optionalDependencies = {} } = member.manifest
79+
const reached = [
80+
...Object.keys(dependencies),
81+
...Object.keys(optionalDependencies),
82+
...tsconfigParents(rootDir, member),
83+
]
84+
queue.push(...reached.filter((next) => members.has(next)))
85+
}
86+
return kept
87+
}
88+
89+
function writeJson(path: string, value: unknown) {
90+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`)
91+
}
92+
93+
/** Drops the members outside `kept`, from disk and from the root's workspace list. */
94+
function narrow(rootDir: string, kept: Set<string>) {
95+
const dropped = new Set<string>()
96+
for (const [name, member] of listMembers(rootDir)) {
97+
const memberDir = join(rootDir, member.dir)
98+
if (!kept.has(name)) {
99+
rmSync(memberDir, { recursive: true, force: true })
100+
dropped.add(member.dir)
101+
continue
102+
}
103+
const { devDependencies: _, ...manifest } = member.manifest
104+
writeJson(join(memberDir, 'package.json'), manifest)
105+
}
106+
// `turbo prune` lists each member's path, and Bun refuses a listed path that no longer exists.
107+
// The root's devDependencies go too: `--omit=dev` skips them, but while they stay in the
108+
// lockfile Bun still links them to satisfy optional peers (better-auth's on vitest).
109+
const rootPath = join(rootDir, 'package.json')
110+
const { devDependencies: _, ...root } = readJson<PackageManifest>(rootPath)
111+
writeJson(rootPath, {
112+
...root,
113+
workspaces: (root.workspaces ?? []).filter((pattern) => !dropped.has(pattern)),
114+
})
115+
}
116+
117+
const [target, fullDir, ...otherDirs] = process.argv.slice(2)
118+
if (!target || !fullDir) {
119+
console.error(
120+
'Usage: bun scripts/prune-production-workspace.ts <package> <full-dir> [<json-dir>]'
121+
)
122+
process.exit(1)
123+
}
124+
125+
const kept = productionClosure(fullDir, listMembers(fullDir), target)
126+
for (const dir of [fullDir, ...otherDirs]) narrow(dir, kept)
127+
console.log(`Production workspace for ${target}: ${[...kept].sort().join(', ')}`)

0 commit comments

Comments
 (0)