Why Our Next.js Deployments Took 30 Minutes
How a 9.48 GB Next.js image became part of an 18–30 minute deployment path, why the shared image was optimized without redesigning tenant workflows, and what changed after rollout.
Frontend deployments could take anywhere from 18 to 30 minutes. Eventually, I started letting changes accumulate so the wait felt more justified.
Sometimes that was fine. Sometimes it meant a change sat longer than it should have. The same delay was harder to accept when the change was a critical fix. I started to dread deploying the frontend, and the wait began influencing when I chose to ship changes.
I did not write the Dockerfile behind those deployments. It was already in the repository when I joined as a junior, and I did not have an opinion about it then. For local development and builds, I usually ran Node.js directly. The Dockerfile stayed mostly outside my field of view until I had more experience and was allowed to perform deployments myself.
Once the cost became part of my own workflow, I investigated why the frontend job took so much longer than two backend jobs in the same pipeline. Inspecting the deployment path made the shipped artifact the next object to measure.
The deployment moved the whole image#
The inspected deployment job did not pull the frontend image from a registry. It used a command shaped like this:
docker save <image> | ssh <server> docker loadIn this incident, the job streamed the complete image to the server and loaded it there. A larger image meant more bytes for that path to transfer and load.
This was also why the two backend services were useful clues. They used the same broad deployment mechanism but shipped a JRE base plus one application JAR. Their jobs completed much sooner. The frontend image used Node.js and Next.js; different runtime stacks need not produce equal image sizes. The comparison only gave us a specific place to investigate.
This does not prove that image transfer consumed every minute of the observed 18-30 minute duration. Compilation, network throughput, remote disk performance, image loading, container replacement, and health checks can all contribute. It does establish that this job moved the entire artifact, making its contents part of deployment performance.
Other pipelines may behave differently. A registry can reuse layers and changes the transfer model. The diagnosis here belongs to the inspected full-image transfer job, not to every slow Docker deploy.
The runtime image still contained the build workspace#
The frontend Dockerfile already used multiple stages, but the final stage copied far more than the application needed at runtime. Structurally, it looked like the following example.
# Simplified, anonymized skeleton — not the production DockerfileFROM node:<full-version> AS buildWORKDIR /app
COPY package*.json ./RUN npm installCOPY . .RUN npm run build
FROM node:<full-version> AS runtimeWORKDIR /app
COPY --from=build /app/node_modules ./node_modulesCOPY --from=build /app/.next ./.nextCOPY --from=build /app/public ./publicCOPY --from=build /app/package.json ./package.json
CMD ["npm", "start"]Multi-stage syntax did not make the runtime image small by itself. The final
COPY instructions still selected the complete dependency directory and the
complete Next.js build directory. That included development dependencies and build
output that the running server did not need.
We built and inspected the existing image rather than guessing from the
Dockerfile. It measured 9.48 GB. The .next directory accounted for 6.55 GB, and
node_modules accounted for another 1.78 GB. In this build, .next included the
persistent webpack cache along with runtime output.
Those numbers showed that the build workspace had become the runtime contract.
Make the runtime boundary explicit#
Next.js standalone mode generated the .next/standalone output used by the final
stage. We enabled it in the Next.js configuration:
// Simplified configuration excerpt — not the production filemodule.exports = { output: 'standalone',};That output gave the runtime stage a narrower set of files to copy. The replacement structure looked like this:
# Simplified, anonymized skeleton — not the production DockerfileFROM node:<version> AS buildWORKDIR /app
COPY package*.json ./RUN npm installCOPY . .RUN npm run build
FROM node:<slim-version> AS runtimeWORKDIR /appENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./COPY --from=build /app/.next/static ./.next/staticCOPY --from=build /app/public ./public
USER nodeCMD ["node", "server.js"]The copy boundary changed. The runtime stage receives the .next/standalone
output, static assets, and public files instead of the full node_modules and
.next directories. It also uses a slim Node base and runs as a non-root user.
Built from the same source, the resulting images landed in the approximate 350-380 MB range. That is about one twenty-fifth of the original size, or roughly 96% smaller.
Size alone was not enough to call the change complete. A narrow copy can omit a
file resolved at runtime. Changing the runtime user can expose a permission
mistake. The smaller artifact had to run the application, not merely finish
docker build.
A smaller image still has to run#
We started the standalone container locally against live user and academic services plus the identity service. Then we completed a real login and navigated through an authenticated part of the application.
That path exercised more than a public health endpoint. It required the frontend server to start, reach its dependencies, complete authentication, serve its assets, and handle an authenticated route. During that validation we saw no runtime errors, missing-file failures, or permission problems.
This test supports a specific conclusion: the reduced image contained what that authenticated application path needed. It does not prove that every route is covered, and it does not measure how long the image will take to reach the target server.
These checks answer different questions. Artifact size answers “How much are we shipping?” A real application flow answers “Did we keep enough for this runtime behavior?” Neither result substitutes for the other.
Why I kept the deployment workflow out of scope#
Changing the deployment workflow was technically possible. I chose not to start there. We deploy per tenant, and some tenants use a different workflow because of internal organizational decisions. Redesigning those paths would have widened the scope beyond the problem I was trying to solve.
The image was shared across those paths, and it was the largest bottleneck we had identified. Improving that artifact let us address the common transfer cost without first changing the tenant-specific workflows.
After rollout, the workflow that had taken roughly 18-30 minutes came down to approximately 5-6 minutes. A second workflow was already faster at roughly 5-10 minutes; with the smaller image, its deployments ranged from a few seconds to approximately 2 minutes.
These are operational observations, not controlled benchmarks. The tenant workflows and their conditions differ, so the two ranges should not be compared as if only one variable changed. Both improved enough to confirm that the image had been a material cost in each deployment path.
Inspect, minimize, test#
I inherited a Dockerfile that I barely interacted with, then later inherited the operational cost of what it shipped. Batching changes made the wait feel more reasonable, but it did not make delivery faster. Looking inside the artifact gave us a concrete source of transfer work to address.
This case supports three checks for an inherited runtime image:
- Inspect the actual runtime image and measure its largest contents.
- Minimize the final-stage copy boundary to what the application needs at runtime.
- Test a real application path that can expose missing files, permissions, and dependency assumptions.
For this case, the first two steps reduced the image to about one twenty-fifth of its previous size. The third showed that a real authenticated flow still worked. The rollout then reduced one deployment path from roughly 18-30 minutes to 5-6 minutes and another from roughly 5-10 minutes to between a few seconds and 2 minutes. Future measurements can show whether another pipeline stage becomes the next dominant cost.


