CROP
ProjectsParts ServicesDocker

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/cli has 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

  1. 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
  2. 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
  3. 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:

  1. Generate lockfile on Linux:

    docker run --rm -v $PWD:/repo -w /repo oven/bun:1.3.6-debian \
      bun install --ignore-scripts
  2. Use --frozen-lockfile everywhere:

    RUN bun install --frozen-lockfile --ignore-scripts
  3. 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 install

Benefits:

  • 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 /ready endpoint (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 MB

Tips

  1. Combine RUN commands to reduce layers
  2. Clean up in same layer: apt-get update && ... && rm -rf /var/lib/apt/lists/*
  3. Use .dockerignore: Exclude node_modules, .git, etc.
  4. 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 10

Health 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:

  1. Recommended: Use pragmatic strategy (remove --frozen-lockfile)
  2. 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_modules

Verify 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 MB

References


Last updated: 2025-11-13 Related: services/search/Dockerfile, services/search/cloudbuild.yaml

On this page