Docker Strategy for CROP Microservices
Architecture Overview
Docker Strategy for CROP Microservices
Architecture Overview
All services use a 4-stage Docker build pattern optimized for:
- Security: Non-root execution, minimal attack surface
- Performance: BuildKit cache mounts, layer optimization
- Reproducibility: Pinned base images, pragmatic lockfile strategy
4-Stage Pattern
┌─────────────────────────────────────────────────────────────┐
│ Stage 1: base │
│ - Base Bun image (pinned version) │
│ - Workspace setup (/repo) │
└────────────────────┬────────────────────────────────────────┘
│
┌────────────┴────────────┐
↓ ↓
┌───────────────────┐ ┌────────────────────┐
│ Stage 2: deps-dev │ │ Stage 3: deps-prod │
│ - ALL deps │ │ - ONLY prod deps │
│ - Dev tools │ │ - No esbuild │
│ - Build deps │ │ - No biome │
└─────────┬─────────┘ └─────────┬──────────┘
│ │
└────────────┬───────────┘
↓
┌─────────────────┐
│ Stage 4: prod │
│ - Minimal │
│ - Non-root │
│ - node_modules │
│ from deps-prod│
└─────────────────┘Stage Details
Stage 1: base
FROM oven/bun:1.3.6-debian AS base
WORKDIR /repo
ENV HUSKY=0- Purpose: Common base for all subsequent stages
- Size: ~100MB (Debian + Bun runtime)
Stage 2: deps-dev
FROM base AS deps-dev
ENV NODE_ENV=development
COPY bun.lock package.json ./
COPY packages ./packages
COPY services/search ./services/search
RUN --mount=type=cache,target=/root/.cache/bun \
bun install --ignore-scripts- Purpose: Install ALL dependencies (dev + prod) for builds/tests
- Key Features:
--ignore-scripts: Skip platform-specific postinstalls- Cache mount: Reuses downloads across builds
- Includes: esbuild, biome, typescript, test frameworks
Stage 3: deps-prod
FROM base AS deps-prod
ENV NODE_ENV=production
COPY package.json ./
COPY --from=deps-dev /repo/packages/*/package.json ./packages/
COPY services/search/package.json ./services/search/package.json
RUN --mount=type=cache,target=/root/.cache/bun \
bun install --production- Purpose: Install ONLY production dependencies
- Key Features:
- No lockfile copied (avoids frozen conflicts)
- Bun generates ephemeral lockfile for prod subset
- Result: ~296 packages vs ~1436 in deps-dev
- Excludes: All dev tools (esbuild, biome, @types/*)
Stage 4: prod
FROM oven/bun:1.3.2-debian AS prod
WORKDIR /app
ENV NODE_ENV=production
# Copy ONLY from deps-prod (critical!)
COPY --from=deps-prod /repo/node_modules ./node_modules
COPY --from=deps-dev /repo/services/search/src ./src
# Non-root user
RUN useradd -r -s /bin/false app && chown -R app:app /app
USER app
CMD ["bun", "src/index.ts"]- Purpose: Minimal runtime image
- Key Features:
- No curl (no HEALTHCHECK)
- No dev tools
- Non-root execution (app:10001)
- Only tini for signal handling
Lockfile Strategy: Pragmatic
Rationale
Problem: Platform-specific optional dependencies
@biomejs/clihas different binaries for macOS vs Linux@esbuild/*packages have different platform binaries- Lockfile generated on macOS includes macOS-specific binary hashes
- CI/Docker run on Linux → hash mismatch → frozen lockfile errors
Solution: Pragmatic (non-frozen) approach
Implementation
-
deps-dev stage:
RUN bun install --ignore-scripts # NO --frozen-lockfile- Skip postinstall scripts (platform-specific)
- Allow lockfile updates for platform binaries
- Reproducibility: pinned Bun version + committed lockfile
-
deps-prod stage:
# NO lockfile copied RUN bun install --production- Bun generates ephemeral lockfile for prod subset
- Avoids frozen conflicts with deps-dev
- Only 296 production packages
-
CI workflows:
- run: bun install --frozen-lockfile- CI uses frozen for speed
- Lockfile should be regenerated on Linux when deps change
When to Regenerate Lockfile
Regenerate bun.lock on Linux (or CI) when:
- Adding/removing dependencies
- Upgrading Bun version
- Lockfile drift detected
# On Linux CI runner or Cloud Shell
bun install --ignore-scripts
git add bun.lock
git commit -m "chore: regenerate lockfile for Linux"Alternative: Strict Strategy
If you prefer strict validation everywhere:
-
Generate lockfile on Linux:
docker run --rm -v $PWD:/repo -w /repo oven/bun:1.3.6-debian \ bun install --ignore-scripts -
Use
--frozen-lockfileeverywhere:RUN bun install --frozen-lockfile --ignore-scripts -
Commit Linux-generated lockfile
Trade-off: More setup complexity, but stricter validation.
BuildKit Features
Cache Mounts
All stages use BuildKit cache mounts for speed:
# APT packages
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y python3 make g++
# Bun packages
RUN --mount=type=cache,target=/root/.cache/bun \
bun installBenefits:
- 5-10x faster builds on cache hit
- Shared across builds (same host)
- Automatic eviction (LRU)
Enabling BuildKit
Local:
export DOCKER_BUILDKIT=1
docker build -f services/search/Dockerfile .Cloud Build (cloudbuild.yaml):
steps:
- name: 'gcr.io/cloud-builders/docker'
env:
- 'DOCKER_BUILDKIT=1'
args: ['build', '-f', 'services/search/Dockerfile', '.']Security Best Practices
1. Non-Root Execution
RUN useradd -r -s /bin/false app && chown -R app:app /app
USER app- Runs as UID 10001 (non-root)
- No shell (
/bin/false) - Reduces blast radius of container escape
2. No HEALTHCHECK in Production
Cloud Run does not use Docker HEALTHCHECK. Instead:
- Cloud Run probes
/readyendpoint (application code) - Removing HEALTHCHECK saves image size (no curl needed)
- Health checks run in CI smoke tests
# ❌ Don't do this for Cloud Run
HEALTHCHECK CMD curl -f http://localhost:3000/health
# ✅ Do this instead
# (no HEALTHCHECK directive, Cloud Run uses /ready)3. Minimal Attack Surface
Production image contains:
- Bun runtime
- Production node_modules (296 packages)
- Application source
- tini (signal handling)
Excluded:
- Development tools (esbuild, biome, typescript)
- Test frameworks
- Build tools (make, g++, python3)
- curl (moved to CI)
Size Optimization
Image Layers
LAYER SIZE STAGE
──────────────────────────────────────
Base Bun image ~100 MB base
node_modules (prod) ~80 MB deps-prod
Application source ~5 MB src
Runtime tools ~2 MB tini
──────────────────────────────────────
TOTAL ~187 MBTips
- Combine RUN commands to reduce layers
- Clean up in same layer:
apt-get update && ... && rm -rf /var/lib/apt/lists/* - Use .dockerignore: Exclude
node_modules,.git, etc. - Multi-stage: Copy only what's needed to final stage
Cloud Run Deployment
Resource Configuration
gcloud run deploy search-service \
--image gcr.io/PROJECT/search-service:latest \
--cpu 2 \
--memory 2Gi \
--timeout 60s \
--concurrency 100 \
--min-instances 1 \
--max-instances 10Health Checks
Cloud Run uses application endpoints:
- Startup: Waits for first successful request
- Liveness:
/live(optional, defaults to /) - Readiness:
/ready(optional, for traffic routing)
// In Hono app
app.get('/ready', async (c) => {
// Check MongoDB, Elasticsearch connectivity
return c.json({ ok: true });
});Troubleshooting
"lockfile had changes, but lockfile is frozen"
Cause: Lockfile hash mismatch (platform-specific deps)
Solutions:
- Recommended: Use pragmatic strategy (remove --frozen-lockfile)
- Alternative: Regenerate lockfile on Linux
"Failed to install 1 package"
Cause: Postinstall script failure (platform-specific)
Solution: Add --ignore-scripts to bun install
"esbuild found in production image"
Cause: Copying node_modules from deps-dev instead of deps-prod
Solution: Verify COPY directives:
# ❌ Wrong
COPY --from=deps-dev /repo/node_modules ./node_modules
# ✅ Correct
COPY --from=deps-prod /repo/node_modules ./node_modulesVerify Production Image
# Check for dev deps
docker run --rm <image> sh -c 'bun pm ls esbuild || echo "OK: esbuild not found"'
# Check user
docker run --rm <image> id
# Expected: uid=10001(app) gid=10001(app)
# Check image size
docker images <image>
# Expected: ~187 MBReferences
Last updated: 2025-11-13
Related: services/search/Dockerfile, services/search/cloudbuild.yaml
Production Pre-Deployment Checklist
> Purpose: This checklist ensures that code changes are production-ready before deployment. > Target: Bun + Hono + TypeScript monorepo ()
GCP Infrastructure & API Gateway Plan
Date: 2025-12-05 Status: In Progress Environment: Development (all services = prod, separation only in database: dev/stage/prod)