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, andapt-get cleanin a singleRUNcommand. For Alpine, useapk add --no-cache.
This reduces the number of layers and ensures that cached package lists don’t bloat the image.# Example for Alpine RUN apk add --no-cache nodejs npm - Why it works: Consolidating package installation and cleanup into a single layer prevents intermediate layers from storing unnecessary package manager cache data.
--no-cacheavoids 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
COPYinstructions. 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.jsonhasn’t changed, Docker will use the cached layer fornpm ci, avoiding a potentially long dependency installation step. Only ifpackage.jsonchanges will this layer and subsequent ones be invalidated.
- Why it works: If your
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
alpinefor 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
COPYstatements. Are you using wildcards (. .) that might be copying more than intended? - Fix: Be explicit about what you copy. Use
.dockerignoreto exclude files and directories that should never be sent to the Docker daemon, and be precise with yourCOPYcommands.# 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.
.dockerignoreprevents unnecessary files from even being sent to the daemon.
- 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.
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
RUNcommand 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
RUNcommand 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.
- Why it works: This single
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), ifpackage.jsondoesn’t change, thatdepsstage is cached. Then, when building thebuilderstage, we copy the cachednode_modulesfromdepsand then copy the source code. If only the source code changes, only thebuilderstage needs to be re-run, not thedepsstage.
- Why it works: By separating dependency installation into its own 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.