Skip to main content

2 - Docker for Frontend Developers: Containerizing Angular

2 - Docker per Sviluppatori Frontend: Containerizzare Angular: Introduzione: perchè Docker per il Frontend? Quante volte hai sentito la frase "ma sul mio...

What do you think about this article?

At a Glance

2 - Docker per Sviluppatori Frontend: Containerizzare Angular: Introduzione: perchè Docker per il Frontend? Quante volte hai sentito la frase "ma sul mio...

Introduction: Why Docker for Frontend?

How many times have you heard "but it works on my machine"? Docker solves this problem at its root: every environment, from development to production, runs exactly the same container with the same dependencies, the same Node.js version, and the same system configuration.

For frontend developers, Docker is not just a backend tool. Containerizing an Angular application means having 100% reproducible builds, consistent development environments across team members, and a simplified deployment process. In this article we will build an optimized multi-stage Dockerfile, configure nginx for SPA routing, and create a complete development environment with Docker Compose.

What You Will Learn in This Article

  • Why Docker is useful for frontend projects
  • How to build a multi-stage Dockerfile for Angular
  • How to configure .dockerignore for faster builds
  • How to use Docker Compose for local development
  • How to manage environment variables at build time
  • How to configure nginx for SPA routing
  • Docker layer caching optimization
  • Health checks and container debugging

1. Docker Basics for Frontend

Before writing our Dockerfile, it is important to understand three key Docker concepts in the context of a frontend application:

Docker in the Frontend Context

Concept Description Frontend Use
Image An immutable template containing everything needed to run the application Compiled Angular app + nginx
Container A running instance of an image The nginx server serving static files
Multi-stage build A Dockerfile with multiple phases, where only the final result is included in the image Phase 1: build with Node.js, Phase 2: serve with nginx

The advantage of multi-stage builds is that the final image does not contain Node.js, npm, or node_modules: only the compiled static files and nginx. This drastically reduces the image size from about 1.5 GB to less than 50 MB.

2. The .dockerignore File

Before creating the Dockerfile, let us configure .dockerignore. This file works like .gitignore but for Docker build context: everything excluded is not sent to the Docker daemon, enormously speeding up the build.


# .dockerignore
node_modules
dist
.angular
.git
.github
.vscode
.idea

# Configuration files not needed for the build
*.md
LICENSE
.editorconfig
.prettierrc
.eslintrc*

# Docker files (avoid recursion)
Dockerfile*
docker-compose*
.dockerignore

# Test files
coverage
e2e
*.spec.ts

# Local environment files
.env.local
.env.development.local
      

Do Not Forget node_modules!

Excluding node_modules from the build context is crucial: without this exclusion, Docker will send hundreds of megabytes of files to the daemon on every build, even though the Dockerfile runs npm ci to install dependencies from scratch. The build can go from 2 minutes to 30 seconds simply by adding this line.

3. Multi-Stage Dockerfile for Angular

Here is the optimized multi-stage Dockerfile for an Angular application. The first stage performs the build, the second serves the static files with nginx:


# ============================================
# Stage 1: Build the Angular application
# ============================================
FROM node:20-alpine AS build

# Set the working directory
WORKDIR /app

# Copy dependency files FIRST (for layer caching)
COPY package.json package-lock.json ./

# Install dependencies
RUN npm ci --ignore-scripts

# Copy the rest of the source code
COPY . .

# Run the production build
RUN npx ng build --configuration=production

# ============================================
# Stage 2: Serve with nginx
# ============================================
FROM nginx:1.25-alpine AS production

# Remove the default nginx configuration
RUN rm /etc/nginx/conf.d/default.conf

# Copy our nginx configuration
COPY nginx.conf /etc/nginx/conf.d/

# Copy the compiled files from the build stage
COPY --from=build /app/dist/my-app/browser /usr/share/nginx/html

# Expose port 80
EXPOSE 80

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1

# Start nginx in foreground
CMD ["nginx", "-g", "daemon off;"]
      

Note the order of COPY instructions: first we copy package.json and package-lock.json, then run npm ci, and only after that copy the source code. This order is fundamental for Docker layer caching.

Why COPY Order Matters

Docker builds images in layers. Each instruction creates a new layer, and Docker reuses layers from cache when input files have not changed. If we copy everything together with a single COPY . ., any change to any file invalidates the dependency installation cache. With the correct order:

  • Source code change: only the last 2 layers are rebuilt
  • Change to package.json: rebuilt from npm ci onwards
  • No changes: near-instant build from cache

4. Nginx Configuration for SPA

Single Page Applications like Angular handle routing on the client side. This means nginx must serve index.html for any path that does not match a static file. Here is the configuration:


# nginx.conf
server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml;

    # Cache for static files with hash in the filename
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # SPA fallback: any path not found is served by index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Disable directory listing
    autoindex off;

    # Hide nginx version
    server_tokens off;
}
      

Key Points of the Nginx Configuration

  • try_files $uri $uri/ /index.html: the fundamental rule for SPA routing
  • gzip: compression to reduce transferred file sizes
  • expires 1y: aggressive caching for hashed files (Angular uses automatic hashing)
  • Security headers: basic protection against XSS and clickjacking

5. Build-Time Environment Variables

A common challenge with containerized Angular applications is managing environment variables. Angular compiles variables into the bundle at build time, so we need to pass them as build arguments:


# Dockerfile with build arguments
FROM node:20-alpine AS build
WORKDIR /app

# Define build arguments
ARG API_URL=http://localhost:3000
ARG ENVIRONMENT=production
ARG GOOGLE_ANALYTICS_ID

# Set environment variables for the build
ENV API_URL=${API_URL}
ENV ENVIRONMENT=${ENVIRONMENT}
ENV GOOGLE_ANALYTICS_ID=${GOOGLE_ANALYTICS_ID}

COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .

# Use the configuration matching the environment
RUN npx ng build --configuration=${ENVIRONMENT}

FROM nginx:1.25-alpine AS production
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/
COPY --from=build /app/dist/my-app/browser /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
      

To pass build arguments during the build:


# Build with custom variables
docker build \
  --build-arg API_URL=https://api.example.com \
  --build-arg ENVIRONMENT=production \
  --build-arg GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX \
  -t my-angular-app:latest .

# Build for staging
docker build \
  --build-arg API_URL=https://staging-api.example.com \
  --build-arg ENVIRONMENT=staging \
  -t my-angular-app:staging .
      

6. Docker Compose for Development

For local development, Docker Compose allows you to orchestrate multiple services with a single command. Here is a configuration that includes the Angular app with hot-reload, a backend API, and a database:


# docker-compose.yml
services:
  angular-app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "4200:4200"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
    command: npx ng serve --host 0.0.0.0

  api:
    image: node:20-alpine
    working_dir: /app
    ports:
      - "3000:3000"
    volumes:
      - ./api:/app
    command: npm run dev
    environment:
      - DATABASE_URL=postgresql://user:password@db:5432/mydb

  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
      

Development Dockerfile

The development Dockerfile is different from the production one: it includes Node.js, mounts the source code as a volume, and uses ng serve with hot-reload:


# Dockerfile.dev
FROM node:20-alpine

WORKDIR /app

# Install Angular CLI globally
RUN npm install -g @angular/cli

# Copy dependency files
COPY package.json package-lock.json ./

# Install dependencies
RUN npm ci

# Working directory is mounted as a volume
# so no need to COPY source code

EXPOSE 4200

CMD ["npx", "ng", "serve", "--host", "0.0.0.0", "--poll", "2000"]
      

Essential Docker Compose Commands

Command Description
docker compose up -d Start all services in the background
docker compose down Stop and remove containers
docker compose logs -f angular-app View real-time logs
docker compose exec angular-app sh Open a shell in the container
docker compose build --no-cache Rebuild without cache

7. Docker Build Optimization

Optimizing Docker builds is not just about speed: smaller images mean faster deployments, lower bandwidth consumption, and a reduced attack surface.

Image Size Comparison


# Size comparison with different base images
# node:20 (Debian)      ~1.1 GB
# node:20-slim           ~240 MB
# node:20-alpine         ~130 MB
# nginx:alpine           ~40 MB (only for the final stage)

# Typical final image sizes:
# Without multi-stage:  ~1.5 GB (Node.js + node_modules + dist)
# With multi-stage:     ~45 MB  (nginx + static files)
      

Optimization Strategies


# Optimized Dockerfile with all best practices
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# Install only production dependencies to reduce layers
RUN npm ci --ignore-scripts && npm cache clean --force

FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx ng build --configuration=production
# Remove source maps if not needed
RUN find /app/dist -name "*.map" -delete

FROM nginx:1.25-alpine AS production
# Use a non-root user for security
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/my-app/browser /usr/share/nginx/html
RUN chown -R appuser:appgroup /usr/share/nginx/html && \
    chown -R appuser:appgroup /var/cache/nginx && \
    chown -R appuser:appgroup /var/log/nginx && \
    touch /var/run/nginx.pid && \
    chown appuser:appgroup /var/run/nginx.pid
USER appuser
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
      

8. Health Checks and Debugging

Health checks allow Docker (and orchestrators like Kubernetes) to verify that a container is actually functioning, not just running.


# Health check in Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
      

Useful Debugging Commands


# Check container health status
docker inspect --format='{{.State.Health.Status}}' my-angular-container

# View container logs
docker logs my-angular-container --tail 50

# Enter the running container
docker exec -it my-angular-container sh

# Verify served files
docker exec my-angular-container ls -la /usr/share/nginx/html

# Test nginx response from inside the container
docker exec my-angular-container wget -qO- http://localhost/

# Analyze image layers
docker history my-angular-app:latest

# Check image size
docker images my-angular-app
      

9. Production vs Development Images

It is important to maintain separate Dockerfiles for production and development, as they have very different requirements:

Production vs Development Comparison

Feature Production Development
Base image nginx:alpine node:alpine
Server nginx ng serve (webpack-dev-server)
Hot reload No Yes (with volume mount)
Source maps No (or upload to Sentry) Yes
Size ~45 MB ~500 MB
User Non-root Root (for volume permissions)

Never Use the Development Image in Production

The development image contains Node.js, npm, and all devDependencies. Besides being enormously larger, it exposes a much greater attack surface. Always use the multi-stage Dockerfile for production.

10. Complete Build and Run

Let us summarize the commands to build and start the application in both environments:


# === PRODUCTION ===
# Build the production image
docker build -t my-angular-app:latest .

# Start the container
docker run -d \
  --name angular-prod \
  -p 8080:80 \
  --restart unless-stopped \
  my-angular-app:latest

# Verify it works
curl http://localhost:8080

# === DEVELOPMENT ===
# Start with Docker Compose
docker compose up -d

# Check services
docker compose ps

# View logs
docker compose logs -f angular-app

# Stop everything
docker compose down
      

Conclusion

In this article we explored Docker from the frontend developer's perspective. We built a multi-stage Dockerfile that produces optimized production images under 50 MB, configured nginx for SPA routing with compression and caching, and created a complete development environment with Docker Compose.

The key takeaways are: always use multi-stage builds for production, properly configure .dockerignore, respect the COPY order for layer caching, and maintain separate Dockerfiles for development and production.

In the next article we will see how to integrate Firebase Hosting with GitHub Actions to create a complete automatic deployment flow, with preview channels for Pull Requests and rollback strategies.

Frequently asked questions

What is the main advantage of using a multi-stage build in Docker for frontend applications?

The final image does not contain Node.js, npm, or dependencies, only the compiled static files and nginx, drastically reducing the image size.

Why is it crucial to exclude certain directories from the build context when creating a Dockerfile?

Excluding these directories speeds up the build by preventing hundreds of megabytes of unnecessary files from being sent to the Docker daemon on every build.

What is the correct order for instructions in a multi-stage Dockerfile to enable layer caching?

The correct order is to copy and install dependencies first, then run the build command, and finally copy the source code.

Why should separate Dockerfiles be maintained for development and production environments?

Development images contain Node.js, npm, and devDependencies, exposing a greater attack surface and being much larger than production images.

Sources and further reading

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects