Fly.io’s builder is a highly optimized system, but your Dockerfile can still be a bottleneck.

Let’s see how a typical fly launch or fly deploy works under the hood. When you build an image for Fly.io, we’re actually running docker build on our own infrastructure. This means that the speed of your build is directly tied to how efficiently Docker can process your instructions.

Here’s a simplified view of a Go application build:

# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/main .

# Stage 2: Run
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/main /app/main
EXPOSE 8080
CMD ["/app/main"]

This multi-stage build is a good start. It separates the build environment from the final runtime environment, leading to smaller images. But we can go further.

The biggest win comes from minimizing the number of layers Docker needs to create and ensuring those layers are as small as possible. Each RUN, COPY, and ADD instruction in your Dockerfile creates a new layer. If you have many small operations, you create many small layers, which can slow down the build process and increase image size.

Cause 1: Unnecessary build dependencies. Many Dockerfiles include development tools or libraries that are only needed during the build process itself, not for running the application. For example, installing gcc or make when your application is purely interpreted or already compiled.

  • Diagnosis: Review your build stages. Are there packages installed that aren’t strictly necessary for the final binary or runtime?
  • Fix: In Debian/Ubuntu-based images, chain apt-get update, apt-get install, and apt-get clean in a single RUN command. For Alpine, use apk add --no-cache.
    # Example for Alpine
    RUN apk add --no-cache nodejs npm
    
    This reduces the number of layers and ensures that cached package lists don’t bloat the image.
  • Why it works: Consolidating package installation and cleanup into a single layer prevents intermediate layers from storing unnecessary package manager cache data. --no-cache avoids downloading and storing index files.

Cause 2: Inefficient layer caching. Docker caches layers based on the instructions and the files they operate on. If you change a file early in your Dockerfile, all subsequent layers will be rebuilt, even if the instructions for those later layers haven’t changed.

  • Diagnosis: Look at the order of your COPY instructions. Are you copying files that change frequently (like source code) before files that change rarely (like dependency manifests)?
  • Fix: Copy dependency manifest files (e.g., package.json, go.mod, requirements.txt) and run the dependency installation command before copying your application source code.
    # Example for Node.js
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci --production # Or npm install
    COPY . .
    # ... rest of your build
    
    • Why it works: If your package.json hasn’t changed, Docker will use the cached layer for npm ci, avoiding a potentially long dependency installation step. Only if package.json changes will this layer and subsequent ones be invalidated.

Cause 3: Large base images. While convenient, using a general-purpose base image like ubuntu:latest can include a lot of software you don’t need, leading to larger images and slower downloads.

  • Diagnosis: Check the size of your base image. Are there smaller, more specialized alternatives?
  • Fix: Opt for minimal base images like alpine for general use, or language-specific slim images (e.g., golang:1.21-alpine, node:20-alpine).
    FROM node:20-alpine as builder
    # ...
    FROM alpine:latest
    # ...
    
    • Why it works: Smaller base images mean fewer files to download and unpack, directly speeding up the initial layer pulling and subsequent build steps.

Cause 4: Unnecessary files copied into the image. Your COPY commands might be too broad, pulling in development tools, test files, or documentation that are not needed at runtime.

  • Diagnosis: Review your COPY statements. Are you using wildcards (. .) that might be copying more than intended?
  • Fix: Be explicit about what you copy. Use .dockerignore to exclude files and directories that should never be sent to the Docker daemon, and be precise with your COPY commands.
    # In Dockerfile
    COPY --chown=app:app src/ /app/src/
    COPY --chown=app:app main.go /app/
    
    # In .dockerignore
    .git
    .vscode
    *.md
    test/
    Dockerfile
    
    • Why it works: Explicitly copying only necessary application files reduces the amount of data transferred to the Docker daemon and included in the image layers, leading to faster builds and smaller images. .dockerignore prevents unnecessary files from even being sent to the daemon.

Cause 5: Chaining RUN commands inefficiently. While it’s good to chain commands to reduce layers, doing so without cleaning up intermediate build artifacts can still lead to larger images.

  • Diagnosis: Are you performing multiple distinct build steps within a single RUN command without cleaning up temporary files?
  • Fix: Chain related commands and clean up as you go. For package managers, this means cleaning caches. For compilation, it might mean removing intermediate object files.
    # Example for Python
    RUN apt-get update && \
        apt-get install -y --no-install-recommends python3 python3-pip && \
        pip install --no-cache-dir -r requirements.txt && \
        apt-get clean && \
        rm -rf /var/lib/apt/lists/*
    
    • Why it works: This single RUN command updates, installs, installs dependencies, and then cleans up all caches and temporary files, ensuring the final layer is as small as possible and doesn’t contain any build-time cruft.

Cause 6: Not leveraging build cache effectively with multi-stage builds. Even with multi-stage builds, the order matters. If a step in an earlier stage that’s not copied over invalidates the cache, it can cause unnecessary rebuilds of subsequent stages.

  • Diagnosis: Examine the dependencies between your build stages. Does a change in an early, non-copied stage force a rebuild of later, copied stages?
  • Fix: Ensure that the parts of your Dockerfile that are copied into the final stage are placed as late as possible in the build, after steps that are less likely to change.
    # Stage 1: Build dependencies
    FROM node:20-alpine as deps
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci --production
    
    # Stage 2: Build application
    FROM node:20-alpine as builder
    WORKDIR /app
    COPY --from=deps /app/node_modules /app/node_modules
    COPY . .
    RUN npm run build # This might change frequently
    
    # Stage 3: Final image
    FROM alpine:latest
    WORKDIR /app
    COPY --from=builder /app/dist /app/dist
    CMD ["node", "/app/dist/server.js"]
    
    • Why it works: By separating dependency installation into its own stage (deps), if package.json doesn’t change, that deps stage is cached. Then, when building the builder stage, we copy the cached node_modules from deps and then copy the source code. If only the source code changes, only the builder stage needs to be re-run, not the deps stage.

The next error you’ll encounter after optimizing your Dockerfile is likely a FLY_API_RATE_LIMITED error, as your faster builds might start hitting API rate limits more frequently.

Want structured learning?

Take the full Fly-io course →