DEV Community

Rens Jaspers
Rens Jaspers

Posted on

How to Containerize an Angular App for Production

In this article, we will learn how to containerize an Angular app for production. Our goal is to use the same build for all environments: test, staging, and production, and make sure it is easy to configure, secure, and fast.

Requirements

  1. Single Build for All Environments: We want to build our Angular app once and use that same build for every environment. Normally, Angular encourages you to create different environment files like environment.ts, environment.staging.ts, and environment.prod.ts, which requires creating different builds for different environments. We want to avoid that and use system environment variables instead.

  2. Easy Reverse-Proxy Configuration: Our backend APIs do not support CORS. We prefer to keep everything on a single origin.

  3. Secure and Good Performance: We want to use a web server that is easy to configure, secure, and offers good performance. Using the development server (ng serve) is not recommended for production due to security and performance issues.

  4. Redirects: For deep links in our Angular app, we want to redirect to the Angular app's index.html file.

Using Caddy

Caddy is a web server that is easy to configure and offers good performance. It allows us to set up reverse proxies easily with a Caddyfile and environment variables.

Most guides out there use Nginx as the server component, but I found it very tedious to create a configuration that lets me configure reverse proxy URLs through environment variables.

Example Caddy Configuration

To configure Caddy for our Angular app, we create a Caddyfile that specifies how to serve our app and set up reverse proxies:

# Caddyfile

{
    # Global options can be specified here if needed
    # (currently none are needed)
}

# Angular app server configuration block:
:80 {
    root * ./www
    file_server

    handle_path /api/* {
        reverse_proxy {$API_URL} {
            header_up Host {upstream_hostport}
        }
    }

    handle_path /telemetry/* {
        reverse_proxy {$TELEMETRY_COLLECTOR_URL} {
            header_up Host {upstream_hostport}
            header_up X-API-KEY {$TELEMETRY_API_KEY}
        }
    }

    # Rewrite for deep links if the file does not exist.
    # Without this rule, deep links to Angular routes will return
    # a 404 error (unless you use Angular's HashLocationStrategy).
    @notFound {
        not path /api/*
        not path /telemetry/*
        not file {path}
    }

    rewrite @notFound /index.html
}
Enter fullscreen mode Exit fullscreen mode

This Caddy configuration file does the following:

  1. Serve the Angular App: The root * ./www directive tells Caddy to serve files from the ./www directory. This is where we will copy the Angular build output.

  2. Reverse Proxy Configuration: The handle_path directives set up reverse proxies for the API and telemetry endpoints. We use placeholders like {$API_URL} and {$TELEMETRY_COLLECTOR_URL} to specify the URLs. We can set these placeholders using environment variables.

  3. Rewrite for Deep Links: The rewrite directive handles deep links in the Angular app. If the requested file does not exist, Caddy will rewrite the request to index.html. This is necessary for Angular's HTML5 routing mode.

Example Dockerfile

Next, we need to create a Dockerfile that will build our Angular app and configure Caddy to serve it.

In this example, we have an Angular 18 app set up.

Important: The output path of the Angular build depends on your Angular version and the project name! In my case, it is dist/my-containerized-angular-app-example/browser. This is important because we need to copy the Angular build output to the correct location in the final image. Be sure to check where your Angular build outputs the files (by running ng build manually) and adjust the Dockerfile accordingly.

Here is an example Dockerfile:

# Dockerfile

# Stage 1: Build the Angular app
FROM node:20-alpine as build-angular

# Set the working directory for the build
WORKDIR /build

# Copy the source code for the Angular app to the build directory
COPY . .

# Install the dependencies and build the Angular app
RUN npm install
RUN npm run build

# Stage 2: Create the final image
FROM caddy:latest

# Set the working directory for the final container
WORKDIR /app

# Copy the Angular build output to the final location
COPY --from=build-angular /build/dist/my-containerized-angular-app-example/browser ./www

# Copy the Caddyfile to the final location
COPY Caddyfile /etc/caddy/Caddyfile

# Expose the port for Caddy
EXPOSE 80

# Command to start Caddy
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]
Enter fullscreen mode Exit fullscreen mode

The Dockerfile has two stages:

  1. Build the Angular app: In this stage, we use the node:20-alpine image to build the Angular app. We copy the source code to the build directory, install the dependencies, and build the Angular app using npm install and npm run build.

  2. Create the final image: In this stage, we use the caddy:latest image as the base image. We set the working directory for the final container to /app. We copy the Angular build output from the previous stage to the final location in the container (./www). We also copy the Caddyfile to the final location in the container (/etc/caddy/Caddyfile). We expose port 80 for Caddy, and we specify the command to start Caddy.

Example Docker Ignore File

To keep our Docker image clean and efficient, we create a .dockerignore file to exclude unnecessary files from the build context:

# .dockerignore

# Ignore node_modules
node_modules

# Ignore build outputs
dist
build

# Ignore environment files (secrets)
*.env
.env.*

# Ignore local Angular cache
.angular/cache
Enter fullscreen mode Exit fullscreen mode

Example Docker Compose File

We can use Docker Compose to manage our Docker containers. Here is an example docker-compose.yml file that defines our Angular app service:

services:
  my-containerized-angular-app-example:
    # Build the Docker image using the Dockerfile in the current directory
    build:
      context: .

    # Map port 80 in the container to port 3000 on the host machine
    ports:
      - "3000:80"

    # Load environment variables from the .env file
    env_file:
      - .env
Enter fullscreen mode Exit fullscreen mode

Example Environment File

Finally, we need to create an .env file to store our environment variables. Here is an example .env file:

# .env
API_URL=https://api.example.com
TELEMETRY_COLLECTOR_URL=https://telemetry.example.com
TELEMETRY_API_KEY=your-telemetry-api-key
Enter fullscreen mode Exit fullscreen mode

Building and Running the Docker Container

To build and run the Docker container, we can simply run:

docker compose up
Enter fullscreen mode Exit fullscreen mode

Then our app will be accessible at http://localhost:3000.

If we want to deploy the container to our staging or production environment, we simply change the API_URL and TELEMETRY_COLLECTOR_URL environment variables in the .env file. No rebuild is necessary!

Working Example

For a complete working example, you can visit the GitHub repository: github.com/rensjaspers/containerized-angular-app-example. This repository includes all the files and configurations discussed in this article.

I hope this article helps you containerize your Angular app for production. If you have any questions or feedback, feel free to leave a comment below. Happy coding!

Top comments (2)

Collapse
 
jangelodev profile image
João Angelo

Hi Rens Jaspers,
Top, very nice and helpful !
Thanks for sharing.

Collapse
 
rensjaspers profile image
Rens Jaspers

Thanks, João!