Ask
143
@mulch_mule ·

my node image is 1.4gb and every push takes 9 minutes on a 20mbit uplink Dockerfile

docker images says 1.42 GB for a fairly ordinary Express + Prisma API. I build locally and push to a private registry over home internet, so every deploy is a coffee break.

Dockerfile is basically:

FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npx prisma generate
RUN npm run build
CMD ["npm", "start"]

I know this is bad. I don't know which part is the bad part. Where does 1.4 GB even come from for an app whose source is 3 MB?

12 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @coworking_cass · 4mo ago · 3 replies

    About 1.1 GB of it is the base image. node:22 is Debian bookworm with a full toolchain, python, git, the lot. node:22-slim is around 220 MB, node:22-alpine around 150.

    The rest is that you install dev dependencies and never remove them, and you COPY . . before installing, so every source change invalidates the npm layer and you re-download the world.

    Multi-stage, roughly:

    FROM node:22-slim AS build
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci
    COPY prisma ./prisma
    RUN npx prisma generate
    COPY . .
    RUN npm run build
    
    FROM node:22-slim
    WORKDIR /app
    ENV NODE_ENV=production
    COPY package*.json ./
    RUN npm ci --omit=dev
    COPY --from=build /app/dist ./dist
    COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
    CMD ["node", "dist/server.js"]
    

    That lands around 300 MB for a typical Prisma app. The query engine binary is about 20 MB of it and there isn't much to do about that.

    Also add a .dockerignore with node_modules, .git and dist. Without one you're shipping your local node_modules to the daemon as build context on every single build.

    181
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @mulch_mule · 4mo ago

      1.42 GB down to 297 MB. And the .dockerignore was sending a 900 MB local node_modules as context, which explains why even 'cached' builds felt slow.

      57
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @negativesplitz · 4mo ago

      The COPY ordering is the biggest quality-of-life fix in there. package.json first, install, then source — every build after the first skips the install layer completely.

      29
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @flux_and_solder · 4mo ago · 2 replies

    On alpine vs slim for Prisma specifically: alpine is musl, so you need the musl engine target set in binaryTargets. It works, but it's one more thing to get wrong. If you aren't chasing the last 70 MB, slim is the boring correct answer.

    Same warning for anything with native modules — sharp, bcrypt, better-sqlite3. Alpine will want either a build toolchain or exactly the right prebuilt, and an evening of debugging that costs more than 70 MB is worth.

    63
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @mulch_mule · 4mo ago

      Went with slim for exactly this reason, sharp is in there too.

      21
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @rubberduck_rae · 4mo ago · 2 replies

    Build in CI. Your home uplink is 20 Mbit; a runner in the same region as your registry is on a gigabit link with the layer cache next door. It's free, and it means your deploy no longer depends on your laptop staying open.

    24
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @mulch_mule · 4mo ago

      Fair, though I like being able to deploy without pushing to a remote branch first. Probably a habit worth losing.

      12
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @negativesplitz · 4mo ago

    Your push time is mostly a layer problem rather than a size problem, after the first push. Registries only accept layers they don't already have. If your base and your dependency layer are stable, a deploy pushes the app layer — a few MB.

    So the COPY ordering fix helps push time even more than the size fix does. Get the dependency layer stable and nine minutes becomes about fifteen seconds for a code-only change.

    38
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @muslin_mira · 4mo ago

    docker history <image> sorted by size tells you exactly where it went instead of guessing. docker system df -v for the wider picture. Two minutes and you stop theorising.

    17
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @peer_review_pat · 4mo ago · 2 replies

    distroless gets you to about 120mb, node:22-slim is still carrying a whole userland you never use

    16
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @coworking_cass · 4mo ago

      True, and distroless is a good end state. But no shell means no docker exec sh when something is wrong at 11pm, and for a solo dev debugging on a VPS that trade is worse than 100 MB. Go there once the thing is boring.

      14
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @oncall_omar · 4mo ago

    while you're in there, check you aren't COPYing a .env or your git history into the image. docker history --no-trunc and read it. found a stripe key in one of ours that way

    8
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report