Fly.io’s build cache is the secret sauce for making your deployments blazingly fast, but most people don’t realize it’s not just about having a cache, it’s about how you structure your Dockerfile to hit it effectively.
Let’s see what a cache hit looks like in practice. Imagine you have a simple Node.js app. Here’s a snippet of fly deploy output when the cache is working:
...
CACHED: docker buildx build --platform linux/amd64 --tag registry.fly.io/my-app:deployment-123 --cache-from registry.fly.io/my-app:cache --cache-to registry.fly.io/my-app:cache --output "type=image,oci-mediatypes=true" .
...
See that CACHED: prefix? That means Docker didn’t re-run that specific build step; it pulled the result from the cache. Now, let’s dive into how to make that happen consistently.
The core idea behind Docker’s build cache is that each instruction in your Dockerfile generates a layer. If Docker sees the same instruction and the same context (files involved) as a previous build, it can reuse that layer instead of executing the instruction again. This is revolutionary for build times, especially for complex applications.
Here’s a typical Dockerfile for a Node.js app, and we’ll break down how to optimize it for caching:
# Stage 1: Builder
FROM node:20-alpine as builder
WORKDIR /app
# Copy only package.json and package-lock.json first
COPY package*.json ./
# Install dependencies - this layer is heavily cached
RUN npm ci --only=production
# Copy the rest of the application code
COPY . .
# Build the application if necessary (e.g., for TypeScript)
RUN npm run build
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
# Copy only necessary artifacts from the builder stage
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
# Expose the port your app listens on
EXPOSE 3000
# Define the command to run your application
CMD ["node", "dist/index.js"]
The magic happens with the order of your COPY and RUN commands.
COPY package*.json ./: This is the most critical line for caching Node.js dependency installs. By copying only yourpackage.jsonandpackage-lock.json(oryarn.lock) first, you create a distinct layer.RUN npm ci --only=production: This command installs your dependencies. If yourpackage*.jsonfiles haven’t changed since the last build, Docker will use the cached layer from thisRUNcommand. This is where you’ll see the biggest time savings becausenpm cican often take a significant amount of time. Ifpackage*.jsonhas changed, this layer (and all subsequent layers) will be rebuilt.COPY . .: This copies the entire rest of your application code. This layer will be rebuilt if any file in your project directory changes.RUN npm run build: If your application requires a build step (like transpiling TypeScript), this command will run. It depends on the previousCOPY . .layer, so it will be rebuilt if your source code changes.
The multi-stage build (Stage 1: builder, Stage 2: Production) is also a performance win, though not directly a cache speed win. It dramatically reduces the final image size by only copying the necessary artifacts (compiled code and production dependencies) into the final image, making subsequent pushes and pulls of the image faster.
The one thing that trips most people up is not ordering their Dockerfile to put the least frequently changing instructions first. If you have a COPY . . instruction near the top of your Dockerfile, any code change will invalidate the cache for that step and all subsequent steps, even if only a single file changed. By separating dependency installation from code copying, you ensure that code changes don’t force a reinstall of dependencies every single time.
If you’re using a language with a compiled asset step, like Go or Rust, ensure your dependency management files (e.g., go.mod, Cargo.toml) are copied and their dependencies are installed before you copy your source code.
The next thing you’ll want to master is optimizing your .dockerignore file to prevent unnecessary files from being copied into the build context, which can also lead to cache invalidation.