Docker and TypeScript: Advanced Application Containerization
\n\nContainerizing TypeScript applications with Docker is not just about packaging code into an image. At an advanced level, it is an art of optimization, security, and automation. In this article, we will explore how to create efficient Docker images for TypeScript, manage environments, and integrate containerization into a CI/CD pipeline. You will learn how to reduce image size by 5-10 times, speed up builds, and improve dependency isolation.
\n\n1. Multi-stage Builds for TypeScript
\n\nThe classic problem with Docker images for TypeScript is including all development tools in the final image: the TypeScript compiler, dev dependencies, and source files. This leads to huge sizes (often > 1 GB). The solution is multi-stage builds.
\n\nThe idea is simple: the first stage (build) compiles TypeScript into JavaScript and installs all dependencies. The second stage (production) copies only the ready JavaScript and production dependencies.
\n\n# Stage 1: Build\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=development\nCOPY . .\nRUN npm run build\n\n# Stage 2: Production\nFROM node:20-alpine AS production\nWORKDIR /app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/package*.json ./\nRUN npm ci --only=production\nEXPOSE 3000\nCMD ["node", "dist/index.js"]\n\nThis approach reduces the image size from ~800 MB to ~150 MB. Note the --only=development and --only=production flags — these are key for separating dependencies.
2. Optimizing Docker Layer Caching
\n\nEach instruction in a Dockerfile creates a layer. Docker caches layers if they haven't changed. The correct order of instructions is critical for rebuild speed.
\n\nBad example (breaks cache):
\nFROM node:20-alpine\nWORKDIR /app\nCOPY . .\nRUN npm ci\nRUN npm run build\nCMD ["node", "dist/index.js"]\n\nHere, any change to the source code (even in README.md) invalidates the cache for npm ci and the build.
\n\nGood example (preserves cache):
\nFROM node:20-alpine AS builder\nWORKDIR /app\nCOPY package*.json tsconfig.json ./\nRUN npm ci --only=development\nCOPY src ./src\nRUN npm run build\n\nWhy does this work? Dependencies change rarely, while source code changes often. By copying package*.json separately, we ensure the layer with npm ci is only rebuilt when these files change.
3. Working with Environment Variables and Secrets
\n\nFor TypeScript applications on Node.js, using environment variables is standard. However, Docker has its nuances.
\n\nCommon mistake: passing sensitive data (API keys, passwords) via ENV in the Dockerfile. This leaves secrets in the image layer history.
Correct approach:
\n- \n
- Use
--env-filewhen running the container \n - For production, use Docker Secrets (in Swarm) or Kubernetes Secrets \n
- For local development, use a
.envfile withdocker-compose\n
Example docker-compose.yml:
\nversion: '3.8'\nservices:\n app:\n build: .\n ports:\n - "3000:3000"\n env_file:\n - .env.production\n volumes:\n - ./logs:/app/logs\n\nIn TypeScript code, use the dotenv library to load variables:
import dotenv from 'dotenv';\nimport { resolve } from 'path';\n\ndotenv.config({ path: resolve(__dirname, '../.env') });\n\nconst config = {\n port: parseInt(process.env.PORT || '3000', 10),\n dbUrl: process.env.DATABASE_URL,\n apiKey: process.env.API_KEY\n};\n\nexport default config;\n\nImportant: never commit real .env files