Introduction
TypeScript AI agents can become surprisingly heavy Docker images.
At first, the service may look small. It is just a Node.js app that calls an LLM, uses a few tools, stores some state, and exposes an API. Then the dependencies start growing. You add the OpenAI SDK, LangChain or another agent framework, Prisma, a database client, Playwright for browser automation, test utilities, TypeScript, build tools, and maybe a few internal packages.
Before long, the Docker image is much larger than expected. It might still run, but the hidden cost shows up in CI, deployments, registry storage, cold starts, and security scans.
This article walks through a practical optimization path for a TypeScript AI agent. The goal is not to chase the smallest possible image. The goal is to remove obvious waste, keep the runtime image focused, and use tools like Dive to understand what is actually inside the container.
Why Image Size Matters for AI Agent Services
Large Docker images are not only a storage problem. They slow down CI pipelines because every build and deployment may need to push or pull hundreds of extra megabytes. They slow down new environments because each new instance needs the image before the app can start. They also increase the security surface because more packages usually mean more things to scan, patch, and maintain.
For AI agent services, this matters even more because the app often includes tooling that is not needed at runtime. TypeScript compilers, test frameworks, browser binaries, local development utilities, and generated artifacts can accidentally end up in the final image. If the agent only needs to run compiled JavaScript and call APIs, the final image should not include everything used to build, test, and develop the project.
The Unoptimized Dockerfile
A common first Dockerfile looks like this:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
This works, but it has several problems.
It uses the full Node.js base image. It copies the entire project into the image, including files that may not be needed. It installs development dependencies. It keeps TypeScript source, tests, local configuration, and build tools in the same image that runs in production. It also makes Docker layer caching less effective because every source change can invalidate dependency installation.
For a TypeScript AI agent, that can mean shipping a runtime image that contains testing libraries, Playwright setup files, development-only packages, local documentation, and other files that are not needed once the app is compiled.
The Better Mental Model
A production Docker image should answer one question: what does this service need to run?
For a TypeScript AI agent, the runtime usually needs compiled JavaScript, production dependencies, package metadata, environment configuration, and maybe Prisma-generated client files or migration-related assets depending on how you deploy.
It usually does not need TypeScript compiler dependencies, unit tests, Cypress or Playwright test specs, coverage reports, local .env files, source maps in some production environments, .git history, or development caches.
Multi-stage builds help enforce that separation.
Optimized Multi-Stage Dockerfile
Here is a cleaner Dockerfile for a TypeScript AI agent API:
# syntax=docker/dockerfile:1.7
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
This is a much better starting point.
The build stage installs all dependencies and compiles the TypeScript code. The runtime stage starts fresh, installs only production dependencies, and copies only the compiled output from the build stage. The final image does not include the TypeScript compiler, test files, or most development tooling.
The node:22-slim base image keeps broad compatibility while avoiding the size of the full Node.js image. Alpine can be smaller, but it can introduce compatibility issues with native dependencies. Many TypeScript AI apps use packages that depend on native modules, database clients, or browser-related libraries, so slim is often the safer first optimization.
Add a Proper .dockerignore
The Dockerfile is only part of the optimization. The build context also matters. If Docker receives your entire repository as context, you may accidentally copy unnecessary files into the image or slow down builds.
A basic .dockerignore for this kind of project can look like this:
node_modules
dist
coverage
playwright-report
cypress/videos
cypress/screenshots
.git
.github
.env
.env.*
.npmrc
*.log
README.md
docs
tests
__tests__
*.spec.ts
*.test.ts
Be careful with this file. Do not ignore files that your build actually needs. For example, if your app needs Prisma schema files during build, include them intentionally:
COPY prisma ./prisma
RUN npx prisma generate
The key is to be deliberate. A Docker image should not receive the entire repository just because COPY . . was easy.
Where Dive Helps
Multi-stage builds are useful, but they do not tell you exactly what is inside the image. Dive helps with that.
Dive is an open-source tool for exploring Docker images layer by layer. It shows which command created each layer, which files were added or changed, and where wasted space may exist. This makes it easier to see whether your image still contains unexpected files such as test reports, cached package data, source files, or large browser binaries.
Install Dive locally, then analyze the image:
brew install dive
docker build -t ai-agent-api:optimized .
dive ai-agent-api:optimized
When you open the image in Dive, look for a few things. Check which layers are the largest. Look for files that should not exist in production. Verify that the final image does not include your test folders, local .env files, coverage reports, or source repository metadata. Check whether node_modules includes only production dependencies. Look at the efficiency score, but do not treat it as the only goal.
The best use of Dive is not to obsess over every kilobyte. It is to make the image visible. Once you can see the layers, waste becomes much easier to remove.
Example: AI Agent with Playwright
Browser automation is common in agent workflows. A support agent may open a web page, a QA agent may validate a flow, or a research agent may inspect a site. Playwright is powerful, but it can also make images much larger because browsers and system dependencies are heavy.
The important question is whether Playwright is needed in the same runtime image as your API.
If browser automation is only used in tests, do not include it in the production image. Keep Playwright in a separate test image or CI step:
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
playwright-tests:
image: mcr.microsoft.com/playwright:v1.56.1-noble
working_dir: /app
volumes:
- ./:/app
command: sh -c "npm ci && npx playwright test"
If the agent truly needs browser automation at runtime, consider isolating that capability into a separate browser worker service instead of bloating the main API image.
This separation keeps the main API smaller and makes the browser automation boundary more explicit.
Safer Dependency Installation
For production images, prefer npm ci over npm install. It uses the lockfile and gives more reproducible installs:
RUN npm ci --omit=dev && npm cache clean --force
If your project uses pnpm, the same idea applies. Install from the lockfile and avoid shipping development dependencies:
RUN corepack enable
RUN pnpm install --frozen-lockfile --prod
Also avoid installing packages at runtime. An AI agent should not dynamically install npm packages in production unless you have a very controlled sandbox and a strong reason. Runtime package installation makes the supply chain harder to scan and the image harder to reproduce.
Before and After Pattern
The unoptimized version often looks like this:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
The optimized version should look more like this:
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
The exact size reduction will depend on your project. A small API may only save a few hundred megabytes. An AI agent with browser tooling, dev dependencies, generated reports, and cached files may see a much larger improvement. The "900MB to 150MB" story is realistic for some messy Node.js images, but it should be treated as an example, not a promise.
Add Image Checks to CI
Dive can also run in CI mode with thresholds:
CI=true dive ai-agent-api:optimized --ci-config .dive-ci
A simple .dive-ci file can enforce a minimum efficiency score:
rules:
- name: efficiency
key: efficiency
operation: ">="
value: 0.90
This should not be your only quality gate, but it can catch obvious regressions. For example, if someone accidentally copies Playwright reports, .git, or local datasets into the image, the image size and efficiency score may change enough to fail the check.
You can also add a simple image size check:
docker image inspect ai-agent-api:optimized \
--format='{{.Size}}'
In mature teams, image optimization becomes part of the same quality loop as tests, linting, and vulnerability scanning.
Practical Trade-offs
Optimization has trade-offs.
The smallest image is not always the best image. Alpine images are smaller, but native Node.js packages may require extra work. Distroless images reduce attack surface, but they are harder to debug because they do not include a shell. Aggressive cleanup can make troubleshooting painful. Multi-stage builds improve runtime images, but they may add complexity to the Dockerfile.
For most TypeScript AI agents, the best starting point is simple: use a slim base image, use multi-stage builds, exclude unnecessary files, install only production dependencies, run as a non-root user, and inspect the result with Dive.
That will usually get you most of the benefit without turning the Dockerfile into a maintenance burden.
Conclusion
Docker image optimization is not just a performance trick. For TypeScript AI agents, it is part of reliability and security.
A smaller image pulls faster, deploys faster, scans faster, and usually contains fewer unnecessary files. A cleaner image also makes it easier to understand what your AI service is actually shipping. That matters when the application has access to model APIs, tools, browsers, databases, and credentials.
Start with the obvious improvements. Replace the one-stage Dockerfile with a multi-stage build. Use node:slim instead of the full image. Add a careful .dockerignore. Install only production dependencies in the final stage. Keep Playwright and other heavy tooling out of the main runtime image unless the agent truly needs them. Then use Dive to inspect the image instead of guessing.
The goal is not to win a smallest-image contest. The goal is to ship a focused, understandable, production-ready image for your AI agent.











