Dart 99 🎯 Native Dockerization Strategies for Dart Standalone Backend Services
Dart’s AOT compilation makes it unusually well-suited to containerization. dart compile exe produces a native binary that starts in milliseconds, and the official dart:stable image provides a /runtime/ directory containing only the minimal system libraries that binary needs. The standard pattern is a multi-stage build: compile in the SDK image, copy the binary and the runtime directory into a scratch or minimal base, and ship an image that contains no interpreter, no package manager, and no shell . The result is typically under 20 MB, with startup times measured in tens of milliseconds rather than hundreds .
Key point: FROM scratch produces the smallest and most secure image, but it has no shell and no diagnostic tools. Health checks must be handled by the orchestrator, or the runtime stage must include a probe binary. The alternative is a minimal Debian image with a non-root user and a health-check tool, which trades size for debuggability.
Why native Dockerization is the right fit for Dart
The startup problem. A Dart server run with dart run requires the Dart VM to start, load the application’s kernel files, and JIT-compile the code paths that execute. A compiled binary is already machine code; the operating system loads it and jumps to the entry point. The Dart team’s own Docker documentation recommends the AOT path specifically because it produces “very small runtime images (~10 MB)” .
The dependency problem. The /runtime/ directory in the official Dart image contains the shared libraries and configuration files that an AOT-compiled binary requires. Copying this directory into a scratch image provides exactly what the binary needs and nothing else. Without it, the binary fails silently or crashes on startup . The multi-stage Dockerfile is the mechanism that separates the build-time SDK from the runtime environment.
The security problem. A scratch image has no shell, no curl, no apt, and no package manager. An attacker who gains code execution inside the container has no tools to escalate or persist. This is the security argument for the pattern, and it is why the official documentation presents scratch as the recommended runtime base .
a. The Canonical Multi-Stage Dockerfile
The pattern has three stages: dependency resolution, compilation, and runtime assembly. The first two run in the SDK image. The third copies only the compiled binary and the /runtime/ directory into the final image.
# Stage 1: Build with the Dart SDK
FROM dart:stable AS build
WORKDIR /app
# Resolve dependencies first for better layer caching
COPY pubspec.* ./
RUN dart pub get
# Copy source and compile to a native executable
COPY . .
RUN dart pub get --offline
RUN dart compile exe bin/server.dart -o bin/server
# Stage 2: Minimal runtime image
FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/bin/server /app/bin/
EXPOSE 8080
CMD ["/app/bin/server"]
The COPY pubspec.* before COPY . . is the layer-caching optimization: if the dependencies do not change, Docker reuses the dart pub get layer on subsequent builds. The dart pub get --offline after the source copy ensures that any new dependencies introduced by the source are resolved without network access, using the cache populated by the first pub get.
For dependency caching with BuildKit, the RUN --mount=type=cache syntax caches the pub cache across builds without baking it into the image layer :
RUN --mount=type=cache,target=/root/.pub-cache \
dart pub get
RUN --mount=type=cache,target=/root/.pub-cache \
dart pub get --offline && \
dart compile exe bin/server.dart -o bin/server
b. When scratch Is Not Appropriate
The scratch image has no curl or wget, which means Docker’s HEALTHCHECK instruction cannot run inside the container . The health check must be performed by the orchestrator—Kubernetes liveness and readiness probes, or a load balancer’s health endpoint—rather than by Docker itself. For a single-container deployment without an orchestrator, this is a limitation.
The alternative is a minimal Debian runtime with a non-root user and a health-check tool. The image is larger, but it provides a shell for debugging and the ability to run HEALTHCHECK.
# Stage 1: Build
FROM dart:stable AS build
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
RUN dart pub get --offline
RUN dart compile exe bin/server.dart -o bin/server
# Stage 2: Debian runtime with health check capability
FROM debian:bookworm-slim
# Create a non-root user
RUN useradd --system --create-home appuser
# Install a probe tool for HEALTHCHECK
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
# Copy runtime libraries and binary
COPY --from=build /runtime/ /
COPY --from=build /app/bin/server /app/bin/server
USER appuser
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
EXPOSE 8080
CMD ["/app/bin/server"]
The non-root user is not optional for production. The official Dart Docker documentation and the dart-services reference Dockerfile both create a dedicated user and switch to it before running the application . The scratch image cannot do this because it has no /etc/passwd; running as non-root in a scratch image requires the orchestrator to enforce a UID via securityContext in Kubernetes or --user in Docker.
c. Choosing the Runtime Base
The decision between scratch, alpine, and debian:bookworm-slim depends on what the deployment needs.
| Base | Size | Shell | Health Check | Non-Root | Use Case |
|---|---|---|---|---|---|
scratch | ~10–20 MB | No | Orchestrator only | Orchestrator-enforced | Production with K8s probes |
alpine | ~10–15 MB + runtime | Yes (sh) | Possible | Yes (adduser) | Minimal with shell access |
debian:bookworm-slim | ~80–100 MB | Yes (bash) | Yes (wget/curl) | Yes (useradd) | Debuggable production |
The Serverpod project uses alpine:latest as its runtime base, copying the /runtime/ directory and the compiled binary . The dartapi tool generates a Dockerfile with a debian:bookworm-slim runtime . The official Dart documentation recommends scratch as the default . Each choice is a different point on the size-versus-operability curve.
The scratch image is not “more secure” in an absolute sense; it is more constrained. A container that runs as root in a scratch image is still root, even if there is no shell to exploit. The non-root user must be enforced by the orchestrator, or the base must be one that supports USER.
Complete Example Session
# ============================================
# PART 1: PROJECT STRUCTURE
# ============================================
# my_server/
# ├── bin/server.dart
# ├── pubspec.yaml
# └── Dockerfile
# ============================================
# PART 2: BUILD STAGE — DEPENDENCIES
# ============================================
FROM dart:stable AS build
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
# ============================================
# PART 3: BUILD STAGE — COMPILE
# ============================================
COPY . .
RUN dart pub get --offline
RUN dart compile exe bin/server.dart -o bin/server
# ============================================
# PART 4: SCRATCH RUNTIME — MINIMAL
# ============================================
FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/bin/server /app/bin/
EXPOSE 8080
CMD ["/app/bin/server"]
# ============================================
# PART 5: BUILD AND RUN
# ============================================
# docker build -t my-server .
# docker run -p 8080:8080 my-server
# ============================================
# PART 6: DEBIAN RUNTIME — WITH HEALTHCHECK
# ============================================
FROM debian:bookworm-slim
RUN useradd --system --create-home appuser
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /runtime/ /
COPY --from=build /app/bin/server /app/bin/server
USER appuser
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -q --spider http://localhost:8080/health || exit 1
EXPOSE 8080
CMD ["/app/bin/server"]
# ============================================
# PART 7: ALPINE RUNTIME — SERVERPOD STYLE
# ============================================
FROM alpine:latest
RUN adduser -D -H appuser
COPY --from=build /runtime/ /
COPY --from=build /app/bin/server /app/bin/server
USER appuser
EXPOSE 8080
ENTRYPOINT ["/app/bin/server"]
# ============================================
# PART 8: BUILDKIT CACHE MOUNT
# ============================================
RUN --mount=type=cache,target=/root/.pub-cache \
dart pub get
RUN --mount=type=cache,target=/root/.pub-cache \
dart pub get --offline && \
dart compile exe bin/server.dart -o bin/server
# ============================================
# PART 9: HEALTH CHECK ENDPOINT (SERVER-SIDE)
# ============================================
# The server must expose /health for the probe
# import 'package:shelf/shelf.dart';
# Router().get('/health', (Request r) => Response.ok('ok'));
# ============================================
# PART 10: VERIFY IMAGE SIZE
# ============================================
# docker images my-server
# REPOSITORY TAG SIZE
# my-server latest ~15MB (scratch)
The ten parts trace the Dockerfile from the build stage through the three runtime base options. Parts 4, 6, and 7 show the same application packaged three different ways.
Quick Reference
Runtime Base Comparison
| Base | Image Size | Shell | HEALTHCHECK | Non-Root Support |
|---|---|---|---|---|
scratch | ~10–20 MB | No | No | Orchestrator only |
alpine:latest | ~10–15 MB + libs | Yes (sh) | Yes | adduser |
debian:bookworm-slim | ~80–100 MB | Yes (bash) | Yes (wget) | useradd |
Dockerfile Stages
| Stage | Base | Purpose |
|---|---|---|
| Build | dart:stable | Resolve deps, compile binary |
| Runtime (minimal) | scratch | Copy binary + /runtime/ |
| Runtime (debug) | debian:bookworm-slim | Add user, probe, shell |
Essential Build Commands
| Command | Purpose |
|---|---|
dart pub get | Resolve dependencies |
dart pub get --offline | Verify cache after source copy |
dart compile exe bin/server.dart -o bin/server | AOT compile |
COPY --from=build /runtime/ / | Copy runtime libraries |
Best Practices
✅ Do This:
# Copy pubspec first for layer caching
COPY pubspec.* ./
RUN dart pub get # ✅
# Copy the runtime directory into scratch
COPY --from=build /runtime/ / # ✅
# Use a non-root user in debian/alpine runtime
USER appuser # ✅
# Use BuildKit cache mount for pub cache
RUN --mount=type=cache,target=/root/.pub-cache dart pub get # ✅
❌ Don’t Do This:
# Use dart:stable as the runtime base
FROM dart:stable # includes full SDK, ~1GB # ❌
# Copy only the binary into scratch without /runtime/
FROM scratch
COPY --from=build /app/bin/server /bin/ # binary crashes # ❌
# Run as root in debian/alpine
CMD ["./server"] # no USER instruction # ❌
# Expect Docker HEALTHCHECK to work in scratch
HEALTHCHECK CMD curl ... # curl does not exist # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Binary crashes in scratch | /runtime/ not copied | COPY --from=build /runtime/ / |
| Image is ~1 GB | Used dart:stable as runtime | Use scratch or debian-slim |
| HEALTHCHECK fails silently | No curl/wget in scratch | Use orchestrator probes or Debian base |
| Container runs as root | No USER instruction | useradd + USER appuser |
| Build is slow | No layer caching | Copy pubspec first; use cache mounts |
Real-World Examples
1. Minimal Scratch Build
FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/bin/server /bin/
CMD ["/bin/server"]
2. Debian with Health Check
FROM debian:bookworm-slim
RUN useradd --system appuser && apt-get install -y wget
USER appuser
HEALTHCHECK CMD wget -q --spider http://localhost:8080/health
3. Alpine with Non-Root
FROM alpine:latest
RUN adduser -D appuser
USER appuser
4. BuildKit Cache
RUN --mount=type=cache,target=/root/.pub-cache dart pub get
5. Copy Pubspec First
COPY pubspec.* ./
RUN dart pub get
6. AOT Compile
RUN dart compile exe bin/server.dart -o bin/server
7. Health Endpoint
router.get('/health', (Request r) => Response.ok('ok'));
8. Verify Image Size
docker images my-server
9. Run with Port
docker run -p 8080:8080 my-server
10. Orchestrator Probe
livenessProbe:
httpGet:
path: /health
port: 8080
Visual
The Multi-Stage Build
┌──────────────────────────────────────────────────────────────┐
│ BUILD STAGE (dart:stable, ~1 GB) │
│ │
│ ┌─────────────────┐ │
│ │ pubspec.yaml │────► dart pub get │
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ source code │────► dart compile exe │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ bin/server │ Native machine code │
│ │ /runtime/ │ Minimal shared libraries │
│ └────────┬────────┘ │
│ │ COPY --from=build │
│ ▼ │
│ RUNTIME STAGE (scratch, ~15 MB) │
│ ┌─────────────────┐ │
│ │ /app/bin/server │ No shell, no SDK, no JIT │
│ │ /runtime/ │ Runs directly on host kernel │
│ └─────────────────┘ │
│ │
│ The build stage is discarded. Only the binary and │
│ runtime libraries ship. │
│ │
└──────────────────────────────────────────────────────────────┘
Runtime Base Decision
┌──────────────────────────────────────────────────────────────┐
│ DEPLOYMENT TARGET? │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Orchestrator │ │ Single container│ │
│ │ (K8s, ECS) │ │ (Docker Compose)│ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ scratch │ │ debian-slim │ │
│ │ ~15 MB │ │ ~90 MB │ │
│ │ Probes external │ │ HEALTHCHECK ok │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ Need shell? │────► alpine or debian-slim │
│ │ Debugging? │ │
│ └─────────────────┘ │
│ │
│ The smallest image is not always the right image. │
│ │
└──────────────────────────────────────────────────────────────┘
Layer Caching
┌──────────────────────────────────────────────────────────────┐
│ OPTIMIZED LAYER ORDER │
│ │
│ COPY pubspec.* ./ │
│ RUN dart pub get ← cached if pubspec unchanged │
│ COPY . . │
│ RUN dart pub get --offline │
│ RUN dart compile exe ← re-runs on source change │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Source change: only compile layer rebuilds ││
│ │ Dependency change: pub get and compile rebuild ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ Copying pubspec before source is the single biggest │
│ build-time improvement. │
│ │
└──────────────────────────────────────────────────────────────┘
Security Surface
┌──────────────────────────────────────────────────────────────┐
│ SCRATCH DEBIAN-SLIM │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ No shell │ │ bash, sh │ │
│ │ No curl/wget │ │ wget │ │
│ │ No apt │ │ apt │ │
│ │ No package mgr │ │ Package manager │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Attack surface: minimal Attack surface: larger │
│ Debugging: none Debugging: possible │
│ Health check: orchestrator Health check: Docker │
│ │
│ Choose based on operational needs, not on size alone. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Build stage | dart:stable; dart pub get + dart compile exe |
| Runtime (minimal) | scratch; copy binary + /runtime/ |
| Runtime (debug) | debian:bookworm-slim or alpine; add user + probe |
| Runtime directory | /runtime/ in official Dart image; required by binary |
| Image size (scratch) | ~10–20 MB |
| Health check (scratch) | Orchestrator probes only |
| Health check (debian) | HEALTHCHECK with wget |
| Non-root (scratch) | Orchestrator-enforced UID |
| Non-root (debian) | useradd + USER |
| Build cache | COPY pubspec.* first; BuildKit cache mounts |
Key takeaways:
dart compile exeplusFROM scratchproduces the smallest viable image. The compiled binary is native machine code, and the/runtime/directory from the official Dart image provides the minimal shared libraries it needs. The result is typically under 20 MB with sub-50ms startup .- The
/runtime/directory is mandatory inscratch. Without it, the binary fails silently or crashes on startup. TheCOPY --from=build /runtime/ /instruction is not optional . scratchcannot run DockerHEALTHCHECK. The image has nocurl,wget, or shell. Health checks must be handled by the orchestrator (Kubernetes probes) or the runtime base must be a minimal Debian image with a probe tool .- A non-root user requires a base that supports it.
scratchhas no/etc/passwd, so running as non-root must be enforced by the orchestrator. Debian and Alpine bases supportuseradd/adduserandUSERinstructions . - Layer caching is the build-time optimization. Copying
pubspec.*and runningdart pub getbefore copying the source means dependency resolution is cached until the dependencies change. BuildKit cache mounts for the pub cache provide the same benefit across builds .
Remember: Dart’s Docker story is the same story as its compilation story. The AOT compiler produces a native binary; the Dockerfile packages that binary with the minimal runtime it needs. The scratch image is the purest expression of this: no SDK, no interpreter, no shell, just the machine code and the libraries it links against. The trade-off is operational: a scratch image cannot be debugged from inside, and its health must be checked from outside. The Debian and Alpine bases are the compromise: larger, but with the tools that production operations expect. The choice between them is not a matter of correctness; it is a matter of what the deployment environment can provide.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!