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
.dockerignorefor 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 fromnpm cionwards - 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 routinggzip: compression to reduce transferred file sizesexpires 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.
Related deep dive: 1 - GitHub Actions: CI/CD per App Angular da Zero.
Related deep dive: 3 - Firebase Hosting + GitHub Actions: Deploy Automatico.
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.
Commenti
Caricamento commenti...
Sign in to comment