Guides

Build and run agentic AI applications with Docker

[!TIP]

This guide uses the familiar Docker Compose workflow to orchestrate agentic AI applications. For a smoother development experience, check out Docker Agent, a purpose-built agent runtime that simplifies running and managing AI agents.

Introduction

Agentic applications are transforming how software gets built. These apps don't just respond, they decide, plan, and act. They're powered by models, orchestrated by agents, and integrated with APIs, tools, and services in real time.

All these new agentic applications, no matter what they do, share a common architecture. It's a new kind of stack, built from three core components:

Docker makes this AI-powered stack simpler, faster, and more secure by unifying models, and tool gateways into a developer-friendly workflow that uses Docker Compose.

A diagram of the agentic stack

This guide walks you through the core components of agentic development and shows how Docker ties them all together with the following tools:

For this guide, you'll use the same Compose workflow you're already familiar with. Then, you'll dig into the Compose file, Dockerfile, and app to see how it all works together.

Prerequisites

To follow this guide, you need to:

Step 1: Clone the sample application

You'll use an existing sample application that demonstrates how to connect a model to an external tool using Docker's AI features.

$ git clone https://github.com/docker/compose-for-agents.git
$ cd compose-for-agents/adk/

Step 2: Run the application locally

Your machine must meet the necessary hardware requirements to run the entire application stack locally using Docker Compose. This lets you test the application end-to-end, including the model and MCP gateway, without needing to run in the cloud. This particular example uses the Gemma 3 4B model with a context size of 10000.

Hardware requirements:

If your machine exceeds those requirements, consider running the application with a larger context size or a larger model to improve the agents performance. You can easily update model and context size in the compose.yaml file.

To run the application locally, follow these steps:

  1. In the adk/ directory of the cloned repository, run the following command in a terminal to build and run the application:

    $ docker compose up
    

    The first time you run this command, Docker pulls the model from Docker Hub, which may take some time.

  2. Visit http://localhost:8080. Enter a correct or incorrect fact in the prompt and hit enter. An agent searches DuckDuckGo to verify it and another agent revises the output.

    Screenshot of the application

  3. Press ctrl-c in the terminal to stop the application when you're done.

Step 3: Review the application environment

You can find the compose.yaml file in the adk/ directory. Open it in a text editor to see how the services are defined.

services:
  adk:
    build:
      context: .
    ports:
      # expose port for web interface
      - "8080:8080"
    environment:
      # point adk at the MCP gateway
      - MCPGATEWAY_ENDPOINT=http://mcp-gateway:8811/sse
    depends_on:
      - mcp-gateway
    models:
      gemma3:
        endpoint_var: MODEL_RUNNER_URL
        model_var: MODEL_RUNNER_MODEL

  mcp-gateway:
    # mcp-gateway secures your MCP servers
    image: docker/mcp-gateway:latest
    use_api_socket: true
    command:
      - --transport=sse
      # add any MCP servers you want to use
      - --servers=duckduckgo

models:
  gemma3:
    # pre-pull the model when starting Docker Model Runner
    model: ai/gemma3:4B-Q4_0
    context_size: 10000 # 3.5 GB VRAM
    # increase context size to handle search results
    # context_size: 131000 # 7.6 GB VRAM

The app consists of three main components:

When you examine the compose.yaml file, you'll notice two notable elements for the model:

These two blocks together let Docker Compose automatically start and connect your ADK web app to the specified LLM.

[!TIP]

Looking for more models to use? Check out the Docker AI Model Catalog.

When examining the compose.yaml file, you'll notice the gateway service is a Docker-maintained image, docker/mcp-gateway:latest. This image is Docker's open source MCP Gateway that enables your application to connect to MCP servers, which expose tools that models can call. In this example, it uses the duckduckgo MCP server to perform web searches.

[!TIP]

Looking for more MCP servers to use? Check out the Docker MCP Catalog.

With only a few lines of instructions in a Compose file, you're able to run and connect all the necessary services of an agentic AI application.

In addition to the Compose file, the Dockerfile and the entrypoint.sh script it creates, play a role in wiring up the AI stack at build and runtime. You can find the Dockerfile in the adk/ directory. Open it in a text editor.

# Use Python 3.11 slim image as base
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1

RUN pip install uv

WORKDIR /app
# Install system dependencies
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy \
    uv pip install --system .
# Copy application code
COPY agents/ ./agents/
RUN python -m compileall -q .

COPY <<EOF /entrypoint.sh
#!/bin/sh
set -e

if test -f /run/secrets/openai-api-key; then
    export OPENAI_API_KEY=$(cat /run/secrets/openai-api-key)
fi

if test -n "\${OPENAI_API_KEY}"; then
    echo "Using OpenAI with \${OPENAI_MODEL_NAME}"
else
    echo "Using Docker Model Runner with \${MODEL_RUNNER_MODEL}"
    export OPENAI_BASE_URL=\${MODEL_RUNNER_URL}
    export OPENAI_MODEL_NAME=openai/\${MODEL_RUNNER_MODEL}
    export OPENAI_API_KEY=cannot_be_empty
fi
exec adk web --host 0.0.0.0 --port 8080 --log_level DEBUG
EOF
RUN chmod +x /entrypoint.sh

# Create non-root user
RUN useradd --create-home --shell /bin/bash app \
    && chown -R app:app /app
USER app

ENTRYPOINT [ "/entrypoint.sh" ]

The entrypoint.sh has five key environment variables:

Together, these variables let the same ADK web server code seamlessly target either:

Step 4: Review the application

The adk web application is an agent implementation that connects to the MCP gateway and a model through environment variables and API calls. It uses the ADK (Agent Development Kit) to define a root agent named Auditor, which coordinates two sub-agents, Critic and Reviser, to verify and refine model-generated answers.

The three agents are:

All of the application's behavior is defined in Python under the agents/ directory. Here's a breakdown of the notable files:

The MCP gateway is configured via the MCPGATEWAY_ENDPOINT environment variable. In this case, http://mcp-gateway:8811/sse. This allows the app to use Server-Sent Events (SSE) to communicate with the MCP gateway container, which itself brokers access to external tool services like DuckDuckGo.

Summary

Agent-based AI applications are emerging as a powerful new software architecture. In this guide, you explored a modular, chain-of-thought system where an Auditor agent coordinates the work of a Critic and a Reviser to fact-check and refine model-generated answers. This architecture shows how to combine local model inference with external tool integrations in a structured, modular way.

You also saw how Docker simplifies this process by providing a suite of tools that support agentic AI development:

With these tools, you can develop and test agentic AI applications efficiently, using the same consistent workflow throughout.

Angular language-specific guide

The Angular language-specific guide shows you how to containerize an Angular application using Docker, following best practices for creating efficient, production-ready containers.

Angular is a robust and widely adopted framework for building dynamic, enterprise-grade web applications. However, managing dependencies, environments, and deployments can become complex as applications scale. Docker streamlines these challenges by offering a consistent, isolated environment for development and production.

Acknowledgment

Docker extends its sincere gratitude to Kristiyan Velkov for authoring this guide. As a Docker Captain and experienced Front-end engineer, his expertise in Docker, DevOps, and modern web development has made this resource essential for the community, helping developers navigate and optimize their Docker workflows.


What will you learn?

In this guide, you will learn how to:

You'll start by containerizing an existing Angular application and work your way up to production-level deployments.


Prerequisites

Before you begin, ensure you have a working knowledge of:

Once you've completed the Angular getting started modules, you’ll be fully prepared to containerize your own Angular application using the detailed examples and best practices outlined in this guide.

Containerize an Angular Application

Prerequisites

Before you begin, make sure the following tools are installed and available on your system:

New to Docker?
Start with the Docker basics guide to get familiar with key concepts like images, containers, and Dockerfiles.


Overview

This guide walks you through the complete process of containerizing an Angular application with Docker. You’ll learn how to create a production-ready Docker image using best practices that improve performance, security, scalability, and deployment efficiency.

By the end of this guide, you will:


Get the sample application

Clone the sample application to use with this guide. Open a terminal, navigate to the directory where you want to work, and run the following command to clone the git repository:

$ git clone https://github.com/kristiyan-velkov/docker-angular-sample

Build the Docker image

Angular is a front-end framework that compiles into static assets, so the Dockerfile uses a multi-stage build: one stage compiles the app with Node.js, and a second minimal stage serves the static output with Nginx.

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

Step 1: Create the Dockerfile

Before creating a Dockerfile, you need to choose a base image. You can either use the Node.js Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog.

Choosing DHI offers the advantage of a production-ready image that is lightweight and secure. For more information, see Docker Hardened Images.

[!IMPORTANT] This guide uses a stable Node.js LTS image tag that is considered secure when the guide is written. Because new releases and security patches are published regularly, the tag shown here may no longer be the safest option when you follow the guide. Always review the latest available image tags and select a secure, up-to-date version before building or deploying your application.

Official Node.js Docker Images: https://hub.docker.com/_/node

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}} Docker Hardened Images (DHIs) are available for Node.js in the Docker Hardened Images catalog. Docker Hardened Images are freely available to everyone with no subscription required. You can pull and use them like any other Docker image after signing in to the DHI registry. For more information, see the DHI quickstart guide.

  1. Sign in to the DHI registry:

    $ docker login dhi.io
    
  2. Pull the Node.js DHI (check the catalog for available versions):

    $ docker pull dhi.io/node:24-alpine3.22-dev
    

In the following Dockerfile, the FROM instruction uses dhi.io/node:24-alpine3.22-dev as the base image.

# =========================================
# Stage 1: Build the Angular Application
# =========================================

# Use a lightweight DHI Node.js image for building
FROM dhi.io/node:24-alpine3.22-dev AS builder

# Set the working directory inside the container
WORKDIR /app

# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json package-lock.json* ./

# Install project dependencies using npm ci (ensures a clean, reproducible install)
RUN --mount=type=cache,target=/root/.npm npm ci

# Copy the rest of the application source code into the container
COPY . .

# Build the Angular application
RUN npm run build

# =========================================
# Stage 2: Prepare Nginx to Serve Static Files
# =========================================

FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner

# Copy custom Nginx config
COPY nginx.conf /etc/nginx/nginx.conf

# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=nginx:nginx --from=builder /app/dist/*/browser /usr/share/nginx/html

# Use a non-root user for security best practices
USER nginx

# Expose port 8080 to allow HTTP traffic
# Note: The default Nginx container now listens on port 8080 instead of 80
EXPOSE 8080

# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]

{{< /tab >}} {{< tab name="Using the Docker Official Image" >}}

Create a file named Dockerfile with the following contents:

# =========================================
# Stage 1: Build the Angular Application
# =========================================
ARG NODE_VERSION=24.12.0-alpine
ARG NGINX_VERSION=alpine3.22

# Use a lightweight Node.js image for building (customizable via ARG)
FROM node:${NODE_VERSION} AS builder

# Set the working directory inside the container
WORKDIR /app

# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json *package-lock.json* ./

# Install project dependencies using npm ci (ensures a clean, reproducible install)
RUN --mount=type=cache,target=/root/.npm npm ci

# Copy the rest of the application source code into the container
COPY . .

# Build the Angular application
RUN npm run build

# =========================================
# Stage 2: Prepare Nginx to Serve Static Files
# =========================================

FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runner

# Copy custom Nginx config
COPY nginx.conf /etc/nginx/nginx.conf

# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=nginx:nginx --from=builder /app/dist/*/browser /usr/share/nginx/html

# Use a built-in non-root user for security best practices
USER nginx

# Expose port 8080 to allow HTTP traffic
# Note: The default Nginx container now listens on port 8080 instead of 80
EXPOSE 8080

# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]

[!NOTE] We are using nginx-unprivileged instead of the standard Nginx image to follow security best practices. Running as a non-root user in the final image:

{{< /tab >}} {{< /tabs >}}

Step 2: Create the compose.yaml file

Create a file named compose.yaml with the following contents:

services:
  server:
    build:
      context: .
    ports:
      - 8080:8080

Step 3: Create the .dockerignore file

The .dockerignore file tells Docker which files and folders to exclude when building the image.

[!NOTE] This helps:

To learn more, visit the .dockerignore reference.

Create a file named .dockerignore with the following contents:

# ================================
# Node and build output
# ================================
node_modules
dist
out-tsc
.angular
.cache
.tmp

# ================================
# Testing & Coverage
# ================================
coverage
jest
cypress
cypress/screenshots
cypress/videos
reports
playwright-report
.vite
.vitepress

# ================================
# Environment & log files
# ================================
*.env*
!*.env.production
*.log
*.tsbuildinfo

# ================================
# IDE & OS-specific files
# ================================
.vscode
.idea
.DS_Store
Thumbs.db
*.swp

# ================================
# Version control & CI files
# ================================
.git
.gitignore

# ================================
# Docker & local orchestration
# ================================
Dockerfile
Dockerfile.*
.dockerignore
docker-compose.yml
docker-compose*.yml

# ================================
# Miscellaneous
# ================================
*.bak
*.old
*.tmp

Step 4: Create the nginx.conf file

To serve your Angular application efficiently inside the container, you’ll configure Nginx with a custom setup. This configuration is optimized for performance, browser caching, gzip compression, and support for client-side routing.

Create a file named nginx.conf in the root of your project directory, and add the following content:

[!NOTE] To learn more about configuring Nginx, see the official Nginx documentation.

worker_processes auto;

pid /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    client_body_temp_path /tmp/client_temp;
    proxy_temp_path       /tmp/proxy_temp_path;
    fastcgi_temp_path     /tmp/fastcgi_temp;
    uwsgi_temp_path       /tmp/uwsgi_temp;
    scgi_temp_path        /tmp/scgi_temp;

    # Logging
    access_log off;
    error_log  /dev/stderr warn;

    # Performance
    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;
    keepalive_requests 1000;

    # Compression
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_min_length 256;
    gzip_comp_level 6;
    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/javascript
        application/x-javascript
        application/json
        application/xml
        application/xml+rss
        font/ttf
        font/otf
        image/svg+xml;

    server {
        listen       8080;
        server_name  localhost;

        root /usr/share/nginx/html;
        index index.html;

        # Angular Routing
        location / {
            try_files $uri $uri/ /index.html;
        }

        # Static Assets Caching
        location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|map)$ {
            expires 1y;
            access_log off;
            add_header Cache-Control "public, immutable";
        }

        # Optional: Explicit asset route
        location /assets/ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }
}

Step 5: Build the Angular application image

With your custom configuration in place, you're now ready to build the Docker image for your Angular application.

The updated setup includes:

After completing the previous steps, your project directory should now contain the following files:

├── docker-angular-sample/
│ ├── Dockerfile
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf

Now that your Dockerfile is configured, you can build the Docker image for your Angular application.

[!NOTE] The docker build command packages your application into an image using the instructions in the Dockerfile. It includes all necessary files from the current directory (called the build context).

Run the following command from the root of your project:

$ docker build --tag docker-angular-sample .

What this command does:

Step 6: View local images

After building your Docker image, you can check which images are available on your local machine using either the Docker CLI or Docker Desktop. Since you're already working in the terminal, let's use the Docker CLI.

To list all locally available Docker images, run the following command:

$ docker images

Example Output:

REPOSITORY                TAG               IMAGE ID       CREATED         SIZE
docker-angular-sample     latest            34e66bdb9d40   14 seconds ago   76.4MB

This output provides key details about your images:

If the build was successful, you should see docker-angular-sample image listed.


Run the containerized application

In the previous step, you created a Dockerfile for your Angular application and built a Docker image using the docker build command. Now it’s time to run that image in a container and verify that your application works as expected.

Inside the docker-angular-sample directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:8080. You should see a simple Angular web application.

Press ctrl+c in the terminal to stop your application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the docker-angular-sample directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:8080. You should see your Angular application running in the browser.

To confirm that the container is running, use docker ps command:

$ docker ps

This will list all active containers along with their ports, names, and status. Look for a container exposing port 8080.

Example Output:

CONTAINER ID   IMAGE                          COMMAND                  CREATED             STATUS             PORTS                    NAMES
eb13026806d1   docker-angular-sample-server   "nginx -c /etc/nginx…"   About a minute ago  Up About a minute  0.0.0.0:8080->8080/tcp   docker-angular-sample-server-1

To stop the application, run:

$ docker compose down

[!NOTE] For more information about Compose commands, see the Compose CLI reference.


Use containers for Angular development

Prerequisites

Complete Containerize Angular application.


Overview

In this section, you'll learn how to set up both production and development environments for your containerized Angular application using Docker Compose. This setup allows you to serve a static production build via Nginx and to develop efficiently inside containers using a live-reloading dev server with Compose Watch.

You’ll learn how to:


Automatically update services (development mode)

Use Compose Watch to automatically sync source file changes into your containerized development environment. This provides a seamless, efficient development experience without restarting or rebuilding containers manually.

Step 1: Create a development Dockerfile

Create a file named Dockerfile.dev in your project root with the following content:

# =========================================
# Stage 1: Development - Angular Application
# =========================================

# Define the Node.js version to use (Alpine for a small footprint)
ARG NODE_VERSION=24.12.0-alpine

# Set the base image for development
FROM node:${NODE_VERSION} AS dev

# Set environment variable to indicate development mode
ENV NODE_ENV=development

# Set the working directory inside the container
WORKDIR /app

# Copy only the dependency files first to optimize Docker caching
COPY package.json package-lock.json* ./

# Install dependencies using npm with caching to speed up subsequent builds
RUN --mount=type=cache,target=/root/.npm npm install

# Copy all application source files into the container
COPY . .

# Expose the port Angular uses for the dev server (default is 4200)
EXPOSE 4200

# Start the Angular dev server and bind it to all network interfaces
CMD ["npm", "start", "--", "--host=0.0.0.0"]

This file sets up a lightweight development environment for your Angular application using the dev server.

Step 2: Update your compose.yaml file

Open your compose.yaml file and define two services: one for production (angular-prod) and one for development (angular-dev).

Here’s an example configuration for an Angular application:

services:
  angular-prod:
    build:
      context: .
      dockerfile: Dockerfile
    image: docker-angular-sample
    ports:
      - "8080:8080"

  angular-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "4200:4200"
    develop:
      watch:
        - action: sync
          path: .
          target: /app

[!NOTE] For more details, see the official guide: Use Compose Watch.

After completing the previous steps, your project directory should now contain the following files:

├── docker-angular-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf

Step 4: Start Compose Watch

Run the following command from the project root to start the container in watch mode

$ docker compose watch angular-dev

Step 5: Test Compose Watch with Angular

To verify that Compose Watch is working correctly:

  1. Open the src/app/app.component.html file in your text editor.

  2. Locate the following line:

    <h1>Docker Angular Sample Application</h1>
    
  3. Change it to:

    <h1>Hello from Docker Compose Watch</h1>
    
  4. Save the file.

  5. Open your browser at http://localhost:4200.

You should see the updated text appear instantly, without needing to rebuild the container manually. This confirms that file watching and automatic synchronization are working as expected.


Run Angular tests in a container

Prerequisites

Complete all the previous sections of this guide, starting with Containerize Angular application.

Overview

Testing is a critical part of the development process. In this section, you'll learn how to:

The docker-angular-sample project comes pre-configured with Jasmine, so you can get started quickly without extra setup.


Run tests during development

The docker-angular-sample application includes a sample test file at the following location:

$ src/app/app.component.spec.ts

This test uses Jasmine to validate the AppComponent logic.

Step 1: Update compose.yaml

Add a new service named angular-test to your compose.yaml file. This service allows you to run your test suite in an isolated, containerized environment.

services:
  angular-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "5173:5173"
    develop:
      watch:
        - action: sync
          path: .
          target: /app

  angular-prod:
    build:
      context: .
      dockerfile: Dockerfile
    image: docker-angular-sample
    ports:
      - "8080:8080"

  angular-test:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: ["npm", "run", "test"]

The angular-test service reuses the same Dockerfile.dev used for development and overrides the default command to run tests with npm run test. This setup ensures a consistent test environment that matches your local development configuration.

After completing the previous steps, your project directory should contain the following files:

├── docker-angular-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf

Step 2: Run the tests

To execute your test suite inside the container, run the following command from your project root:

$ docker compose run --rm angular-test

This command will:

You should see output similar to the following:

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.529 s

[!NOTE] For more information about Compose commands, see the Compose CLI reference.


Summary

In this section, you learned how to run unit tests for your Angular application inside a Docker container using Jasmine and Docker Compose.

What you accomplished:


Explore official references and best practices to sharpen your Docker testing workflow:

Introduction to Azure Pipelines with Docker

This guide is a community contribution. Docker would like to thank Kristiyan Velkov for his valuable contribution.

Prerequisites

Before you begin, ensure you have the following requirements:

Overview

This guide walks you through building and pushing Docker images using Azure Pipelines, enabling a streamlined and secure CI workflow for containerized applications. You’ll learn how to:

Set up Azure DevOps to work with Docker Hub

Step 1: Configure a Docker Hub service connection

To securely authenticate with Docker Hub using Azure Pipelines:

  1. Navigate to Project Settings > Service Connections in your Azure DevOps project.
  2. Select New service connection > Docker Registry.
  3. Choose Docker Hub and provide your Docker Hub credentials or access token.
  4. Give the service connection a recognizable name, such as my-docker-registry.
  5. Grant access only to the specific pipeline(s) that require it for improved security and least privilege.

[!IMPORTANT]

Avoid selecting the option to grant access to all pipelines unless absolutely necessary. Always apply the principle of least privilege.

Step 2: Create your pipeline

Add the following azure-pipelines.yml file to the root of your repository:

# Trigger pipeline on commits to the main branch
trigger:
  - main

# Trigger pipeline on pull requests targeting the main branch
pr:
  - main

# Define variables for reuse across the pipeline
variables:
  imageName: 'docker.io/$(dockerUsername)/my-image'
  buildTag: '$(Build.BuildId)'
  latestTag: 'latest'

stages:
  - stage: BuildAndPush
    displayName: Build and Push Docker Image
    jobs:
      - job: DockerJob
        displayName: Build and Push
        pool:
          vmImage: ubuntu-latest
          demands:
            - docker
        steps:
          - checkout: self
            displayName: Checkout Code

          - task: Docker@2
            displayName: Docker Login
            inputs:
              command: login
              containerRegistry: 'my-docker-registry'  # Service connection name

          - task: Docker@2
            displayName: Build Docker Image
            inputs:
              command: build
              repository: $(imageName)
              tags: |
                $(buildTag)
                $(latestTag)
              dockerfile: './Dockerfile'
              arguments: |
                --sbom=true
                --attest type=provenance
                --cache-from $(imageName):latest
            env:
              DOCKER_BUILDKIT: 1

          - task: Docker@2
            displayName: Push Docker Image
            condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
            inputs:
              command: push
              repository: $(imageName)
              tags: |
                $(buildTag)
                $(latestTag)

          # Optional: logout for self-hosted agents
          - script: docker logout
            displayName: Docker Logout (Self-hosted only)
            condition: ne(variables['Agent.OS'], 'Windows_NT')

What this pipeline does

This pipeline automates the Docker image build and deployment process for the main branch. It ensures a secure and efficient workflow with best practices like caching, tagging, and conditional cleanup. Here's what it does:

How the pipeline works

Step 1: Define pipeline triggers

trigger:
  - main

pr:
  - main

This pipeline is triggered automatically on:

[!TIP] Learn more: Define pipeline triggers in Azure Pipelines

Step 2: Define common variables

variables:
  imageName: 'docker.io/$(dockerUsername)/my-image'
  buildTag: '$(Build.BuildId)'
  latestTag: 'latest'

These variables ensure consistent naming, versioning, and reuse throughout the pipeline steps:

[!IMPORTANT]

The variable dockerUsername is not set automatically.
Set it securely in your Azure DevOps pipeline variables:

  1. Go to Pipelines > Edit > Variables
  2. Add dockerUsername with your Docker Hub username

Learn more: Define and use variables in Azure Pipelines

Step 3: Define pipeline stages and jobs

stages:
  - stage: BuildAndPush
    displayName: Build and Push Docker Image

This stage builds the image on both commit and pull request runs. The push task below runs only when the source branch is main.

[!TIP]

Learn more: Stage conditions in Azure Pipelines

Step 4: Job configuration

jobs:
  - job: DockerJob
    displayName: Build and Push
    pool:
      vmImage: ubuntu-latest
      demands:
        - docker

This job utilizes the latest Ubuntu VM image with Docker support, provided by Microsoft-hosted agents. It can be replaced with a custom pool for self-hosted agents if necessary.

[!TIP]

Learn more: Specify a pool in your pipeline

Step 4.1: Checkout code

steps:
  - checkout: self
    displayName: Checkout Code

This step pulls your repository code into the build agent, so the pipeline can access the Dockerfile and application files.

[!TIP]

Learn more: checkout step documentation

Step 4.2: Authenticate to Docker Hub

- task: Docker@2
  displayName: Docker Login
  inputs:
    command: login
    containerRegistry: 'my-docker-registry'  # Replace with your service connection name

Uses a pre-configured Azure DevOps Docker registry service connection to authenticate securely without exposing credentials directly.

[!TIP]

Learn more: Use service connections for Docker Hub

Step 4.3: Build the Docker image

  - task: Docker@2
    displayName: Build Docker Image
    inputs:
      command: build
      repository: $(imageName)
      tags: |
          $(buildTag)
          $(latestTag)
      dockerfile: './Dockerfile'
      arguments: |
          --sbom=true
          --attest type=provenance
          --cache-from $(imageName):latest
    env:
      DOCKER_BUILDKIT: 1

This builds the image with:

[!TIP]

Learn more:

Step 4.4: Push the Docker image

- task: Docker@2
  displayName: Push Docker Image
  condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
  inputs:
      command: push
      repository: $(imageName)
      tags: |
        $(buildTag)
        $(latestTag)

By applying this condition, the pipeline builds the Docker image on every run to ensure early detection of issues, but only pushes the image to the registry when changes are merged into the main branch—keeping your Docker Hub clean and focused

This uploads both tags to Docker Hub:

Step 4.5 Logout of Docker (self-hosted agents)

- script: docker logout
  displayName: Docker Logout (Self-hosted only)
  condition: ne(variables['Agent.OS'], 'Windows_NT')

Summary

With this Azure Pipelines CI setup, you get:

Learn more

Bun language-specific guide

The Bun getting started guide teaches you how to create a containerized Bun application using Docker.

Acknowledgment

Docker would like to thank Pradumna Saraf for his contribution to this guide.

What will you learn?

Prerequisites

After completing the Bun getting started modules, you should be able to containerize your own Bun application based on the examples and instructions provided in this guide.

Start by containerizing an existing Bun application.

Containerize a Bun application

Prerequisites

Overview

For a long time, Node.js has been the de-facto runtime for server-side JavaScript applications. Recent years have seen a rise in new alternative runtimes in the ecosystem, including Bun website. Like Node.js, Bun is a JavaScript runtime. Bun is a comparatively lightweight runtime that is designed to be fast and efficient.

Why develop Bun applications with Docker? Having multiple runtimes to choose from is great. But as the number of runtimes increases, it becomes challenging to manage the different runtimes and their dependencies consistently across environments. This is where Docker comes in. Creating and destroying containers on demand is a great way to manage the different runtimes and their dependencies. Also, as it's fairly a new runtime, getting a consistent development environment for Bun can be challenging. Docker can help you set up a consistent development environment for Bun.

Get the sample application

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository:

$ git clone https://github.com/dockersamples/bun-docker.git && cd bun-docker

You should now have the following contents in your bun-docker directory.

├── bun-docker/
│ ├── compose.yml
│ ├── Dockerfile
│ ├── LICENSE
│ ├── server.js
│ └── README.md

Create a Dockerfile

Before creating a Dockerfile, you need to choose a base image. You can either use the Bun Docker Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog.

Choosing DHI offers the advantage of a production-ready image that is lightweight and secure. For more information, see Docker Hardened Images.

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

Docker Hardened Images (DHIs) are available for Bun in the Docker Hardened Images catalog. You can pull DHIs directly from the dhi.io registry.

  1. Sign in to the DHI registry:

    $ docker login dhi.io
    
  2. Pull the Bun DHI as dhi.io/bun:1. The tag (1) in this example refers to the version to the latest 1.x version of Bun.

    $ docker pull dhi.io/bun:1
    

For other available versions, refer to the catalog.

# Use the DHI Bun image as the base image
FROM dhi.io/bun:1

# Set the working directory in the container
WORKDIR /app

# Copy the current directory contents into the container at /app
COPY . .

# Expose the port on which the API will listen
EXPOSE 3000

# Run the server when the container launches
CMD ["bun", "server.js"]

{{< /tab >}} {{< tab name="Using the official image" >}}

Using the Docker Official Image is straightforward. In the following Dockerfile, you'll notice that the FROM instruction uses oven/bun as the base image.

You can find the image on Docker Hub. This is the Docker Official Image for Bun created by Oven, the company behind Bun, and it's available on Docker Hub.

# Use the official Bun image
FROM oven/bun:latest

# Set the working directory in the container
WORKDIR /app

# Copy the current directory contents into the container at /app
COPY . .

# Expose the port on which the API will listen
EXPOSE 3000

# Run the server when the container launches
CMD ["bun", "server.js"]

{{< /tab >}} {{< /tabs >}}

In addition to specifying the base image, the Dockerfile also:

Run the application

Inside the bun-docker directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:3000. You will see a message {"Status" : "OK"} in the browser.

In the terminal, press ctrl+c to stop the application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the bun-docker directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:3000.

In the terminal, run the following command to stop the application.

$ docker compose down

Use containers for Bun development

Prerequisites

Complete Containerize a Bun application.

Overview

In this section, you'll learn how to set up a development environment for your containerized application. This includes:

Get the sample application

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository:

$ git clone https://github.com/dockersamples/bun-docker.git && cd bun-docker

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Open your compose.yml file in an IDE or text editor and then add the Compose Watch instructions. The following example shows how to add Compose Watch to your compose.yml file.

services:
  server:
    image: bun-server
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    develop:
      watch:
        - action: rebuild
          path: .

Run the following command to run your application with Compose Watch.

$ docker compose watch

Now, if you modify your server.js you will see the changes in real time without re-building the image.

To test it out, open the server.js file in your favorite text editor and change the message from {"Status" : "OK"} to {"Status" : "Updated"}. Save the file and refresh your browser at http://localhost:3000. You should see the updated message.

Press ctrl+c in the terminal to stop your application.

Summary

In this section, you also learned how to use Compose Watch to automatically rebuild and run your container when you update your code.

Use Claude Code with Docker Model Runner

This guide shows how to run Claude Code with Docker Model Runner as the backend model provider. You'll point Claude Code at the local Anthropic-compatible API, run a coding model, and package gpt-oss with a larger context window for longer repository prompts.

Acknowledgment

Docker would like to thank Pradumna Saraf for his contribution to this guide.

In this guide, you'll learn how to:

Prerequisites

Before you start, make sure you have:

If you use Docker Desktop, turn on TCP access in Settings > AI, or run:

$ docker desktop enable model-runner --tcp 12434

Step 1: Pull a coding model

Pull a model before you start Claude Code:

$ docker model pull ai/devstral-small-2

You can also use ai/qwen3-coder if you want another coding-focused model with a large context window.

Step 2: Start Claude Code with Docker Model Runner

Set ANTHROPIC_BASE_URL to your local Docker Model Runner endpoint when you run Claude Code.

On macOS or Linux:

$ ANTHROPIC_BASE_URL=http://localhost:12434 claude --model ai/devstral-small-2

On Windows PowerShell:

$env:ANTHROPIC_BASE_URL="http://localhost:12434"
claude --model ai/devstral-small-2

Claude Code now sends requests to Docker Model Runner instead of Anthropic's hosted API.

Step 3: Troubleshoot your first launch

If Claude Code can't connect, check Docker Model Runner status:

$ docker model status

If Claude Code can't find the model, list local models:

$ docker model ls

If the model is missing, pull it first. If needed, use the fully qualified model name, such as ai/devstral-small-2.

Step 4: Make the endpoint persistent

To avoid setting the environment variable each time, add it to your shell profile:

export ANTHROPIC_BASE_URL=http://localhost:12434

On Windows PowerShell, add it to your PowerShell profile:

$env:ANTHROPIC_BASE_URL = "http://localhost:12434"

After you reload your shell, you can run Claude Code with only the model flag:

$ claude --model ai/devstral-small-2

Step 5: Verify the API endpoint

Send a test request to confirm the Anthropic-compatible API is reachable:

$ curl http://localhost:12434/v1/messages \
  -H "Content-Type: application/json" \
  -d '{
    "model": "ai/devstral-small-2",
    "max_tokens": 32,
    "messages": [{"role": "user", "content": "Say hello"}]
  }'

For more details about the request format, see the Anthropic-compatible API reference.

Step 6: Inspect Claude Code requests

To inspect the requests Claude Code sends to Docker Model Runner, run:

$ docker model requests --model ai/devstral-small-2 | jq .

This helps you debug prompts, context usage, and compatibility issues.

Step 7: Package gpt-oss with a larger context window

ai/gpt-oss defaults to a smaller context window than coding-focused models. If you want to use it for repository-scale prompts, package a larger variant:

$ docker model pull ai/gpt-oss
$ docker model package --from ai/gpt-oss --context-size 32000 gpt-oss:32k

Then run Claude Code with the packaged model:

$ ANTHROPIC_BASE_URL=http://localhost:12434 claude --model gpt-oss:32k

Learn more

Run Claude Code in a Docker Sandbox with Docker Model Runner

This guide shows how to run Claude Code inside a Docker Sandbox with Docker Model Runner as the backend model provider. You'll keep the agent isolated from your host in a microVM, point it at a local model on your machine, and keep all model traffic on-device.

Acknowledgment

Docker would like to thank Pradumna Saraf for his contribution to this guide.

In this guide, you'll learn how to:

How the pieces fit together

Three components cooperate at runtime:

Claude Code inside the sandbox sends requests to http://host.docker.internal:12434. The proxy rewrites the destination to localhost:12434, which Docker Model Runner answers. No model traffic leaves your machine.

Prerequisites

Before you start, make sure you have:

If you use Docker Desktop, turn on TCP access in Settings > AI, or run:

$ docker desktop enable model-runner --tcp 12434

Step 1: Pull a coding model

Pull a model on your host before you create the sandbox:

$ docker model pull ai/devstral-small-2

You can also use ai/qwen3-coder if you want another coding-focused model with a large context window.

Step 2: Allow the sandbox to reach Docker Model Runner

Sandboxes are network-isolated by default, so you need a policy rule before the sandbox can reach Docker Model Runner.

The rule is matched against the destination the proxy forwards to, not the hostname the sandbox uses. Because the proxy rewrites host.docker.internal to localhost before forwarding, the rule allows localhost:12434 even though Claude Code will use host.docker.internal in its requests:

$ sbx policy allow network localhost:12434

For background on host access from sandboxes, see Accessing host services from a sandbox.

Step 3: Create a Claude Code sandbox

From your project directory, create a sandbox without launching the agent. Set ANTHROPIC_BASE_URL so Claude Code uses Docker Model Runner whenever the sandbox starts:

$ cd ~/my-project
$ sbx create --name claude-dmr \
  -e ANTHROPIC_BASE_URL=http://host.docker.internal:12434 \
  claude .

sbx run would also work, but it launches Claude Code immediately. Creating the sandbox first lets you confirm the variable and test connectivity before the agent starts.

You don't need to set an Anthropic API key or run sbx secret set anthropic. Docker Model Runner doesn't authenticate the local endpoint, and the sandbox proxy only injects credentials for requests bound for api.anthropic.com. See Credentials for the full list of services the proxy authenticates. For more ways to set variables, see Set environment variables.

To confirm the variable is set, open a shell in the sandbox:

$ sbx exec -it claude-dmr bash
$ echo $ANTHROPIC_BASE_URL
http://host.docker.internal:12434

Step 4: Verify connectivity to Docker Model Runner

Still inside the sandbox shell, send a test request to the host endpoint:

$ curl http://host.docker.internal:12434/v1/messages \
  -H "Content-Type: application/json" \
  -d '{
    "model": "ai/devstral-small-2",
    "max_tokens": 32,
    "messages": [{"role": "user", "content": "Say hello"}]
  }'

A successful response confirms the policy rule and base URL are correct. Type exit to leave the shell. For more details about the request format, see the Anthropic-compatible API reference.

Step 5: Launch Claude Code with the local model

Run Claude Code in the sandbox and pass the model flag through to the agent:

$ sbx run --name claude-dmr -- --model ai/devstral-small-2

Everything after -- is forwarded to the Claude Code CLI. ANTHROPIC_BASE_URL is stored with the sandbox and available to every session, so Claude Code routes requests to Docker Model Runner on your host instead of api.anthropic.com.

Step 6: Inspect Claude Code requests

To inspect the requests Claude Code sends, run on your host:

$ docker model requests --model ai/devstral-small-2 | jq .

This helps you debug prompts, context usage, and compatibility issues without attaching to the sandbox.

Step 7: Package gpt-oss with a larger context window

ai/gpt-oss defaults to a smaller context window than coding-focused models. To use it for repository-scale prompts, package a larger variant on the host:

$ docker model pull ai/gpt-oss
$ docker model package --from ai/gpt-oss --context-size 32000 gpt-oss:32k

Then point Claude Code at the packaged model the next time you run the sandbox:

$ sbx run --name claude-dmr -- --model gpt-oss:32k

Clean up

Sandboxes persist after Claude Code exits. To stop the sandbox without deleting it:

$ sbx stop claude-dmr

To remove the sandbox and everything inside:

$ sbx rm claude-dmr

Files in your workspace are unaffected.

Learn more

C++ language-specific guide

The C++ getting started guide teaches you how to create a containerized C++ application using Docker. In this guide, you'll learn how to:

Acknowledgment

Docker would like to thank Pradumna Saraf and Mohammad-Ali A'râbi for their contribution to this guide.

After completing the C++ getting started modules, you should be able to containerize your own C++ application based on the examples and instructions provided in this guide.

Start by containerizing an existing C++ application.

Create a multi-stage build for your C++ application

Prerequisites

Overview

This section walks you through creating a multi-stage Docker build for a C++ application. A multi-stage build is a Docker feature that allows you to use different base images for different stages of the build process, so you can optimize the size of your final image and separate build dependencies from runtime dependencies.

The standard practice for compiled languages like C++ is to have a build stage that compiles the code and a runtime stage that runs the compiled binary, because the build dependencies are not needed at runtime.

Get the sample application

Let's use a simple C++ application that prints Hello, World! to the terminal. To do so, clone the sample repository to use with this guide:

$ git clone https://github.com/dockersamples/c-plus-plus-docker.git

The example for this section is under the hello directory in the repository. Get inside it and take a look at the files:

$ cd c-plus-plus-docker/hello
$ ls

You should see the following files:

Dockerfile  hello.cpp

Check the Dockerfile

Open the Dockerfile in an IDE or text editor. The Dockerfile contains the instructions for building the Docker image.

# Stage 1: Build stage
FROM ubuntu:latest AS build

# Install build-essential for compiling C++ code
RUN apt-get update && apt-get install -y build-essential

# Set the working directory
WORKDIR /app

# Copy the source code into the container
COPY hello.cpp .

# Compile the C++ code statically to ensure it doesn't depend on runtime libraries
RUN g++ -o hello hello.cpp -static

# Stage 2: Runtime stage
FROM scratch

# Copy the static binary from the build stage
COPY --from=build /app/hello /hello

# Command to run the binary
CMD ["/hello"]

The Dockerfile has two stages:

  1. Build stage: This stage uses the ubuntu:latest image to compile the C++ code and create a static binary.
  2. Runtime stage: This stage uses the scratch image, which is an empty image, to copy the static binary from the build stage and run it.

Build the Docker image

To build the Docker image, run the following command in the hello directory:

$ docker build -t hello .

The -t flag tags the image with the name hello.

Run the Docker container

To run the Docker container, use the following command:

$ docker run hello

You should see the output Hello, World! in the terminal.

Because the final image uses an empty scratch base, it contains only the static binary and none of the build dependencies or usual OS tools. For example, you can't run a simple ls command in the container:

$ docker run hello ls

The absence of a shell and other tools keeps the image small and reduces its attack surface.

Containerize a C++ application

Prerequisites

Overview

This section walks you through containerizing and running a C++ application, using Docker Compose.

Get the sample application

We're using the same sample repository that you used in the previous sections of this guide. If you haven't already cloned the repository, clone it now:

$ git clone https://github.com/dockersamples/c-plus-plus-docker.git

You should now have the following contents in your c-plus-plus-docker (root) directory.

├── c-plus-plus-docker/
│ ├── compose.yml
│ ├── Dockerfile
│ ├── LICENSE
│ ├── ok_api.cpp
│ └── README.md

To learn more about the files in the repository, see the following:

Run the application

Inside the c-plus-plus-docker directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:8080. You will see a message {"Status" : "OK"} in the browser.

In the terminal, press ctrl+c to stop the application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the c-plus-plus-docker directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:8080.

In the terminal, run the following command to stop the application.

$ docker compose down

For more information about Compose commands, see the Compose CLI reference.

Use containers for C++ development

Prerequisites

Complete Containerize a C++ application.

Overview

In this section, you'll learn how to set up a development environment for your containerized application. This includes:

Get the sample application

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository:

$ git clone https://github.com/dockersamples/c-plus-plus-docker.git && cd c-plus-plus-docker

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Open your compose.yml file in an IDE or text editor and then add the Compose Watch instructions. The following example shows how to add Compose Watch to your compose.yml file.

services:
  ok-api:
    image: ok-api
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    develop:
      watch:
        - action: rebuild
          path: .

Run the following command to run your application with Compose Watch.

$ docker compose watch

Now, if you modify your ok_api.cpp you will see the changes in real time without re-building the image.

To test it out, open the ok_api.cpp file in your favorite text editor and change the message from {"Status" : "OK"} to {"Status" : "Updated"}. Save the file and refresh your browser at http://localhost:8080. You should see the updated message.

Press ctrl+c in the terminal to stop your application.

Summary

In this section, you also learned how to use Compose Watch to automatically rebuild and run your container when you update your code.

Use containerized databases

Using a local containerized database offers flexibility and ease of setup, letting you mirror production environments closely without the overhead of traditional database installations. Docker simplifies this process, enabling you to deploy, manage, and scale databases in isolated containers with just a few commands.

In this guide, you'll learn how to:

This guide uses the MySQL image for examples, but the concepts can be applied to other database images.

Prerequisites

To follow along with this guide, you must have Docker installed. To install Docker, see Get Docker.

Run a local containerized database

To run a database container, you can use either the Docker Desktop GUI or CLI.

{{< tabs group="ui" >}} {{< tab name="CLI" >}}

To run a container using the CLI, run the following command in a terminal:

$ docker run --name my-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -e MYSQL_DATABASE=mydb -d mysql:latest

In this command:

To verify that your container is running, run docker ps in a terminal

{{< /tab >}} {{< tab name="GUI" >}}

To run a container using the GUI:

  1. In the Docker Desktop Dashboard, select the global search at the top of the window.

  2. Specify mysql in the search box, and select the Images tab if not already selected.

  3. Hover over the mysql image and select Run. The Run a new container modal appears.

  4. Expand Optional settings.

  5. In the optional settings, specify the following:

    • Container name: my-mysql
    • Environment variables:
      • MYSQL_ROOT_PASSWORD:my-secret-pw
      • MYSQL_DATABASE:mydb

    The optional settings screen with the options specified.

  6. Select Run.

  7. Open the Container view in the Docker Desktop Dashboard to verify that your container is running.

{{< /tab >}} {{< /tabs >}}

Access the shell of a containerized database

When you have a database running inside a Docker container, you may need to access its shell to manage the database, execute commands, or perform administrative tasks. Docker provides a straightforward way to do this using the docker exec command. Additionally, if you prefer a graphical interface, you can use Docker Desktop's GUI.

If you don't yet have a database container running, see Run a local containerized database.

{{< tabs group="ui" >}} {{< tab name="CLI" >}}

To access the terminal of a MySQL container using the CLI, you can use the following docker exec command.

$ docker exec -it my-mysql bash

In this command:

After executing this command, you will be given access to the bash shell inside your MySQL container, from which you can manage your MySQL server directly. You can run exit to return to your terminal.

{{< /tab >}} {{< tab name="GUI" >}}

  1. Open the Docker Desktop Dashboard and select the Containers view.
  2. In the Actions column for your container, select Show container actions and then select Open in terminal.

In this terminal you can access to the shell inside your MySQL container, from which you can manage your MySQL server directly.

{{< /tab >}} {{< /tabs >}}

Once you've accessed the container's terminal, you can run any tools available in that container. The following example shows using mysql in the container to list the databases.

# mysql -u root -p
Enter password: my-secret-pw

mysql> SHOW DATABASES;

+--------------------+
| Database           |
+--------------------+
| information_schema |
| mydb               |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

Connect to a containerized database from your host

Connecting to a containerized database from your host machine involves mapping a port inside the container to a port on your host machine. This process ensures that the database inside the container is accessible via the host machine's network. For MySQL, the default port is 3306. By exposing this port, you can use various database management tools or applications on your host machine to interact with your MySQL database.

Before you begin, you must remove any containers you previously ran for this guide. To stop and remove a container, either:

Next, you can use either the Docker Desktop GUI or CLI to run the container with the port mapped.

{{< tabs group="ui" >}} {{< tab name="CLI" >}}

Run the following command in a terminal.

$ docker run -p 3307:3306 --name my-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -e MYSQL_DATABASE=mydb -d mysql:latest

In this command, -p 3307:3306 maps port 3307 on the host to port 3306 in the container.

To verify the port is mapped, run the following command.

$ docker ps

You should see output like the following.

CONTAINER ID   IMAGE          COMMAND                  CREATED          STATUS          PORTS                               NAMES
6eb776cfd73c   mysql:latest   "docker-entrypoint.s…"   17 minutes ago   Up 17 minutes   33060/tcp, 0.0.0.0:3307->3306/tcp   my-mysql

{{< /tab >}} {{< tab name="GUI" >}}

To run a container using the GUI:

  1. In the Docker Desktop Dashboard, select the global search at the top of the window.

  2. Specify mysql in the search box, and select the Images tab if not already selected.

  3. Hover over the mysql image and select Run. The Run a new container modal appears.

  4. Expand Optional settings.

  5. In the optional settings, specify the following:

    • Container name: my-mysql
    • Host port for the 3306/tcp port: 3307
    • Environment variables:
      • MYSQL_ROOT_PASSWORD:my-secret-pw
      • MYSQL_DATABASE:mydb

    The optional settings screen with the options specified.

  6. Select Run.

  7. In the Containers view, verify that the port is mapped under the Port(s) column. You should see 3307:3306 for the my-mysql container.

{{< /tab >}} {{< /tabs >}}

At this point, any application running on your host can access the MySQL service in the container at localhost:3307.

Connect to a containerized database from another container

Connecting to a containerized database from another container is a common scenario in microservices architecture and during development processes. Docker's networking capabilities make it easy to establish this connection without having to expose the database to the host network. This is achieved by placing both the database container and the container that needs to access it on the same Docker network.

Before you begin, you must remove any containers you previously ran for this guide. To stop and remove a container, either:

To create a network and run containers on it:

  1. Run the following command to create a Docker network named my-network.

    $ docker network create my-network
    
  2. Run your database container and specify the network using the --network option. This runs the container on the my-network network.

    $ docker run --name my-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -e MYSQL_DATABASE=mydb --network my-network -d mysql:latest
    
  3. Run your other containers and specify the network using the --network option. For this example, you'll run a phpMyAdmin container that can connect to your database.

    1. Run a phpMyAdmin container. Use the --network option to specify the network, the -p option to let you access the container from your host machine, and the -e option to specify a required environment variable for this image.

      $ docker run --name my-phpmyadmin -d --network my-network -p 8080:80 -e PMA_HOST=my-mysql phpmyadmin
      
  4. Verify that the containers can communicate. For this example, you'll access phpMyAdmin and verify that it connects to the database.

    1. Open http://localhost:8080 to access your phpMyAdmin container.
    2. Log in using root as the username and my-secret-pw as the password. You should connect to the MySQL server and see your database listed.

At this point, any application running on your my-network container network can access the MySQL service in the container at my-mysql:3306.

Persist database data in a volume

Persisting database data in a Docker volume is necessary for ensuring that your data survives container restarts and removals. A Docker volume lets you store database files outside the container's writable layer, making it possible to upgrade the container, switch bases, and share data without losing it. Here’s how you can attach a volume to your database container using either the Docker CLI or the Docker Desktop GUI.

Before you begin, you must remove any containers you previously ran for this guide. To stop and remove a container, either:

Next, you can use either the Docker Desktop GUI or CLI to run the container with a volume.

{{< tabs group="ui" >}} {{< tab name="CLI" >}}

To run your database container with a volume attached, include the -v option with your docker run command, specifying a volume name and the path where the database stores its data inside the container. If the volume doesn't exist, Docker automatically creates it for you.

To run a database container with a volume attached, and then verify that the data persists:

  1. Run the container and attach the volume.

    $ docker run --name my-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -e MYSQL_DATABASE=mydb -v my-db-volume:/var/lib/mysql -d mysql:latest
    

    This command mounts the volume named my-db-volume to the /var/lib/mysql directory in the container.

  2. Create some data in the database. Use the docker exec command to run mysql inside the container and create a table.

    $ docker exec my-mysql mysql -u root -pmy-secret-pw -e "CREATE TABLE IF NOT EXISTS mydb.mytable (column_name VARCHAR(255)); INSERT INTO mydb.mytable (column_name) VALUES ('value');"
    

    This command uses the mysql tool in the container to create a table named mytable with a column named column_name, and finally inserts a value of value.

  3. Stop and remove the container. Without a volume, the table you created would be lost when removing the container.

    $ docker rm --force my-mysql
    
  4. Start a new container with the volume attached. This time, you don't need to specify any environment variables as the configuration is saved in the volume.

    $ docker run --name my-mysql -v my-db-volume:/var/lib/mysql -d mysql:latest
    
  5. Verify that the table you created still exists. Use the docker exec command again to run mysql inside the container.

    $ docker exec my-mysql mysql -u root -pmy-secret-pw -e "SELECT * FROM mydb.mytable;"
    

    This command uses the mysql tool in the container to select all the records from the mytable table.

    You should see output like the following.

    column_name
    value
    

{{< /tab >}} {{< tab name="GUI" >}}

To run a database container with a volume attached, and then verify that the data persists:

  1. Run a container with a volume attached.

    1. In the Docker Desktop Dashboard, select the global search at the top of the window.

    2. Specify mysql in the search box, and select the Images tab if not already selected.

    3. Hover over the mysql image and select Run. The Run a new container modal appears.

    4. Expand Optional settings.

    5. In the optional settings, specify the following:

      • Container name: my-mysql
      • Environment variables:
        • MYSQL_ROOT_PASSWORD:my-secret-pw
        • MYSQL_DATABASE:mydb
      • Volumes:
        • my-db-volume:/var/lib/mysql

      The optional settings screen with the options specified.

      Here, the name of the volume is my-db-volume and it is mounted in the container at /var/lib/mysql.

    6. Select Run.

  2. Create some data in the database.

    1. In the Containers view, next to your container select the Show container actions icon, and then select Open in terminal.

    2. Run the following command in the container's terminal to add a table.

      # mysql -u root -pmy-secret-pw -e "CREATE TABLE IF NOT EXISTS mydb.mytable (column_name VARCHAR(255)); INSERT INTO mydb.mytable (column_name) VALUES ('value');"
      

      This command uses the mysql tool in the container to create a table named mytable with a column named column_name, and finally inserts a value of value`.

  3. In the Containers view, select the Delete icon next to your container, and then select Delete forever. Without a volume, the table you created would be lost when deleting the container.

  4. Run a container with a volume attached.

    1. In the Docker Desktop Dashboard, select the global search at the top of the window.

    2. Specify mysql in the search box, and select the Images tab if not already selected.

    3. Hover over the mysql image and select Run. The Run a new container modal appears.

    4. Expand Optional settings.

    5. In the optional settings, specify the following:

      • Container name: my-mysql
      • Environment variables:
        • MYSQL_ROOT_PASSWORD:my-secret-pw
        • MYSQL_DATABASE:mydb
      • Volumes:
        • my-db-volume:/var/lib/mysql

      The optional settings screen with the options specified.

    6. Select Run.

  5. Verify that the table you created still exists.

    1. In the Containers view, next to your container select the Show container actions icon, and then select Open in terminal.

    2. Run the following command in the container's terminal to verify that table you created still exists.

      # mysql -u root -pmy-secret-pw -e "SELECT * FROM mydb.mytable;"
      

      This command uses the mysql tool in the container to select all the records from the mytable table.

      You should see output like the following.

      column_name
      value
      

{{< /tab >}} {{< /tabs >}}

At this point, any MySQL container that mounts the my-db-volume will be able to access and save persisted data.

Build a customized database image

Customizing your database image lets you include additional configuration, scripts, or tools alongside the base database server. This is particularly useful for creating a Docker image that matches your specific development or production environment needs. The following example outlines how to build and run a custom MySQL image that includes a table initialization script.

Before you begin, you must remove any containers you previously ran for this guide. To stop and remove a container, either:

To build and run your custom image:

  1. Create a Dockerfile.

    1. Create a file named Dockerfile in your project directory. For this example, you can create the Dockerfile in an empty directory of your choice. This file will define how to build your custom MySQL image.

    2. Add the following content to the Dockerfile.

      # syntax=docker/dockerfile:1
      
      # Use the base image mysql:latest
      FROM mysql:latest
      
      # Set environment variables
      ENV MYSQL_DATABASE mydb
      
      # Copy custom scripts or configuration files from your host to the container
      COPY ./scripts/ /docker-entrypoint-initdb.d/
      

      In this Dockerfile, you've set the environment variable for the MySQL database name. You can also use the COPY instruction to add custom configuration files or scripts into the container. In this example, files from your host's ./scripts/ directory are copied into the container's /docker-entrypoint-initdb.d/ directory. In this directory, .sh, .sql, and .sql.gz scripts are executed when the container is started for the first time. For more details about Dockerfiles, see the Dockerfile reference.

    3. Create a script file to initialize a table in the database. In the directory where your Dockerfile is located, create a subdirectory named scripts, and then create a file named create_table.sql with the following content.

    CREATE TABLE IF NOT EXISTS mydb.myothertable (
      column_name VARCHAR(255)
    );
    
    INSERT INTO mydb.myothertable (column_name) VALUES ('other_value');
    

    You should now have the following directory structure.

    ├── your-project-directory/
    │ ├── scripts/
    │ │ └── create_table.sql
    │ └── Dockerfile
    
  2. Build your image.

    1. In a terminal, change directory to the directory where your Dockerfile is located.

    2. Run the following command to build the image.

      $ docker build -t my-custom-mysql .
      

      In this command, -t my-custom-mysql tags (names) your new image as my-custom-mysql. The period (.) at the end of the command specifies the current directory as the context for the build, where Docker looks for the Dockerfile and any other files needed for the build.

  3. Run your image as you did in Run a local containerized database. This time, specify your image's name instead of mysql:latest. Also, you no longer need to specify the MYSQL_DATABASE environment variable as it's now defined by your Dockerfile.

    $ docker run --name my-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d my-custom-mysql
    
  4. Verify that your container is running with the following command.

    $ docker ps
    

    You should see output like the following.

    CONTAINER ID   IMAGE              COMMAND                  CREATED        STATUS          PORTS                 NAMES
    f74dcfdb0e59   my-custom-mysql   "docker-entrypoint.s…"    2 hours ago    Up 51 minutes   3306/tcp, 33060/tcp   my-mysql
    
  5. Verify that your initialization script was ran. Run the following command in a terminal to show the contents of the myothertable table.

    $ docker exec my-mysql mysql -u root -pmy-secret-pw -e "SELECT * FROM mydb.myothertable;"
    

    You should see output like the following.

    column_name
    other_value
    

Any container ran using your my-custom-mysql image will have the table initialized when first started.

Use Docker Compose to run a database

Docker Compose is a tool for defining and running multi-container Docker applications. With a single command, you can configure all your application's services (like databases, web apps, etc.) and manage them. In this example, you'll create a Compose file and use it to run a MySQL database container and a phpMyAdmin container.

To run your containers with Docker Compose:

  1. Create a Docker Compose file.

    1. Create a file named compose.yaml in your project directory. This file will define the services, networks, and volumes.

    2. Add the following content to the compose.yaml file.

      services:
        db:
          image: mysql:latest
          environment:
            MYSQL_ROOT_PASSWORD: my-secret-pw
            MYSQL_DATABASE: mydb
          ports:
            - 3307:3306
          volumes:
            - my-db-volume:/var/lib/mysql
      
        phpmyadmin:
          image: phpmyadmin/phpmyadmin:latest
          environment:
            PMA_HOST: db
            PMA_PORT: 3306
            MYSQL_ROOT_PASSWORD: my-secret-pw
          ports:
            - 8080:80
          depends_on:
            - db
      
      volumes:
        my-db-volume:
      

      For the database service:

      • db is the name of the service.
      • image: mysql:latest specifies that the service uses the latest MySQL image from Docker Hub.
      • environment lists the environment variables used by MySQL to initialize the database, such as the root password and the database name.
      • ports maps port 3307 on the host to port 3306 in the container, allowing you to connect to the database from your host machine.
      • volumes mounts my-db-volume to /var/lib/mysql inside the container to persist database data.

      In addition to the database service, there is a phpMyAdmin service. By default Compose sets up a single network for your app. Each container for a service joins the default network and is both reachable by other containers on that network, and discoverable by the service's name. Therefore, in the PMA_HOST environment variable, you can specify the service name, db, in order to connect to the database service. For more details about Compose, see the Compose file reference.

  2. Run Docker Compose.

    1. Open a terminal and change directory to the directory where your compose.yaml file is located.

    2. Run Docker Compose using the following command.

      $ docker compose up
      

      You can now access phpMyAdmin at http://localhost:8080 and connect to your database using root as the username and my-secret-pw as the password.

    3. To stop the containers, press ctrl+c in the terminal.

Now, with Docker Compose you can start your database and app, mount volumes, configure networking, and more, all with a single command.

Summary

This guide introduced you to the essentials of using containerized databases, specifically focusing on MySQL, to enhance flexibility, ease of setup, and consistency across your development environments. The use-cases covered in this guide not only streamline your development workflows but also prepare you for more advanced database management and deployment scenarios, ensuring your data-driven applications remain robust and scalable.

Deno language-specific guide

The Deno getting started guide teaches you how to create a containerized Deno application using Docker.

Acknowledgment

Docker would like to thank Pradumna Saraf for his contribution to this guide.

What will you learn?

Prerequisites

After completing the Deno getting started modules, you should be able to containerize your own Deno application based on the examples and instructions provided in this guide.

Start by containerizing an existing Deno application.

Containerize a Deno application

Prerequisites

Overview

For a long time, Node.js has been the go-to runtime for server-side JavaScript applications. However, recent years have introduced new alternative runtimes, including Deno. Like Node.js, Deno is a JavaScript and TypeScript runtime, but it takes a fresh approach with modern security features, a built-in standard library, and native support for TypeScript.

Why develop Deno applications with Docker? Having a choice of runtimes is exciting, but managing multiple runtimes and their dependencies consistently across environments can be tricky. This is where Docker proves invaluable. Using containers to create and destroy environments on demand simplifies runtime management and ensures consistency. Additionally, as Deno continues to grow and evolve, Docker helps establish a reliable and reproducible development environment, minimizing setup challenges and streamlining the workflow.

Get the sample application

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository:

$ git clone https://github.com/dockersamples/docker-deno.git && cd docker-deno

You should now have the following contents in your deno-docker directory.

├── deno-docker/
│ ├── compose.yml
│ ├── Dockerfile
│ ├── LICENSE
│ ├── server.ts
│ └── README.md

Understand the sample application

The sample application is a simple Deno application that uses the Oak framework to create a simple API that returns a JSON response. The application listens on port 8000 and returns a message {"Status" : "OK"} when you access the application in a browser.

// server.ts
import { Application, Router } from "https://deno.land/x/oak@v12.0.0/mod.ts";

const app = new Application();
const router = new Router();

// Define a route that returns JSON
router.get("/", (context) => {
  context.response.body = { Status: "OK" };
  context.response.type = "application/json";
});

app.use(router.routes());
app.use(router.allowedMethods());

console.log("Server running on http://localhost:8000");
await app.listen({ port: 8000 });

Create a Dockerfile

Before creating a Dockerfile, you need to choose a base image. You can either use the Deno Docker Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog.

Choosing DHI offers the advantage of a production-ready image that is lightweight and secure. For more information, see Docker Hardened Images.

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

Docker Hardened Images (DHIs) are available for Deno in the Docker Hardened Images catalog. You can pull DHIs directly from the dhi.io registry.

  1. Sign in to the DHI registry:

    $ docker login dhi.io
    
  2. Pull the Deno DHI as dhi.io/deno:2. The tag (2) in this example refers to the version to the latest 2.x version of Deno.

    $ docker pull dhi.io/deno:2
    

For other available versions, refer to the catalog.

# Use the DHI Deno image as the base image
FROM dhi.io/deno:2

# Set the working directory
WORKDIR /app

# Copy server code into the container
COPY server.ts .

# Set permissions (optional but recommended for security)
USER deno

# Expose port 8000
EXPOSE 8000

# Run the Deno server
CMD ["run", "--allow-net", "server.ts"]

{{< /tab >}} {{< tab name="Using the official image" >}}

Using the Docker Official Image is straightforward. In the following Dockerfile, you'll notice that the FROM instruction uses denoland/deno:latest as the base image.

This is the official image for Deno. This image is available on the Docker Hub.

# Use the official Deno image
FROM denoland/deno:latest

# Set the working directory
WORKDIR /app

# Copy server code into the container
COPY server.ts .

# Set permissions (optional but recommended for security)
USER deno

# Expose port 8000
EXPOSE 8000

# Run the Deno server
CMD ["run", "--allow-net", "server.ts"]

{{< /tab >}} {{< /tabs >}}

In addition to specifying the base image, the Dockerfile also:

Run the application

Make sure you are in the deno-docker directory. Run the following command in a terminal to build and run the application.

$ docker compose up --build

Open a browser and view the application at http://localhost:8000. You will see a message {"Status" : "OK"} in the browser.

In the terminal, press ctrl+c to stop the application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the deno-docker directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:8000.

In the terminal, run the following command to stop the application.

$ docker compose down

Use containers for Deno development

Prerequisites

Complete Containerize a Deno application.

Overview

In this section, you'll learn how to set up a development environment for your containerized application. This includes:

Get the sample application

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository:

$ git clone https://github.com/dockersamples/docker-deno.git && cd docker-deno

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Open your compose.yml file in an IDE or text editor and then add the Compose Watch instructions. The following example shows how to add Compose Watch to your compose.yml file.

services:
  server:
    image: deno-server
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    develop:
      watch:
        - action: rebuild
          path: .

Run the following command to run your application with Compose Watch.

$ docker compose watch

Now, if you modify your server.ts you will see the changes in real time without re-building the image.

To test it out, open the server.ts file in your favorite text editor and change the message from {"Status" : "OK"} to {"Status" : "Updated"}. Save the file and refresh your browser at http://localhost:8000. You should see the updated message.

Press ctrl+c in the terminal to stop your application.

Summary

In this section, you also learned how to use Compose Watch to automatically rebuild and run your container when you update your code.

Mocking OAuth services in testing with Dex

Dex is an open-source OpenID Connect (OIDC) and OAuth 2.0 identity provider that can be configured to authenticate against various backend identity providers, such as LDAP, SAML, and OAuth. Running Dex in a Docker container allows developers to simulate an OAuth 2.0 server for testing and development purposes. This guide will walk you through setting up Dex as an OAuth mock server using Docker containers.

Nowadays OAuth is the preferred choice to authenticate in web services, the highest part of them give the possibility to access using popular OAuth services like GitHub, Google or Apple. Using OAuth guarantees a higher level of security and simplification since it is not necessary to create new profiles for each service. This means that, by allowing applications to access resources on behalf of users without sharing passwords, OAuth minimizes the risk of credential exposure.

In this guide, you'll learn how to:

Using Dex with Docker

The official Docker image for Dex provides a convenient way to deploy and manage Dex instances. Dex is available for various CPU architectures, including amd64, armv7, and arm64, ensuring compatibility with different devices and platforms. You can learn more about Dex standalone on the Dex docs site.

Prerequisites

Docker Compose: Recommended for managing multi-container Docker applications.

Setting up Dex with Docker

Begin by creating a directory for your Dex project:

mkdir dex-mock-server
cd dex-mock-server

Organize your project with the following structure:

dex-mock-server/
├── config.yaml
└── compose.yaml

Create the Dex Configuration File: The config.yaml file defines Dex's settings, including connectors, clients, and storage. For a mock server setup, you can use the following minimal configuration:

# config.yaml
issuer: http://localhost:5556/dex
storage:
  type: memory
web:
  http: 0.0.0.0:5556
staticClients:
  - id: example-app
    redirectURIs:
      - 'http://localhost:5555/callback'
    name: 'Example App'
    secret: ZXhhbXBsZS1hcHAtc2VjcmV0
enablePasswordDB: true
staticPasswords:
  - email: "admin@example.com"
    hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
    username: "admin"
    userID: "1234"

Explanation:

[!NOTE]

Ensure the hash is a valid bcrypt hash of your desired password. You can generate this using tools like bcrypt-generator.com. or use CLI tools like htpasswd like in this following example:echo password | htpasswd -BinC 10 admin | cut -d: -f2

With Docker Compose configured, start Dex:

# compose.yaml

services:
  dex:
    image: dexidp/dex:latest
    container_name: dex
    ports:
      - "5556:5556"
    volumes:
      - ./config.yaml:/etc/dex/config.yaml
    command: ["dex", "serve", "/etc/dex/config.yaml"]

Now it is possible to run the container using the docker compose command.

docker compose up -d

This command will download the Dex Docker image (if not already available) and start the container in detached mode.

To verify that Dex is running, check the logs to ensure Dex started successfully:

docker compose logs -f dex

You should see output indicating that Dex is listening on the specified port.

Using Dex OAuth testing in GHA

To test the OAuth flow, you'll need a client application configured to authenticate against Dex. One of the most typical use cases is to use it inside GitHub Actions. Since Dex supports mock authentication, you can predefine test users as suggested in the docs. The config.yaml file should looks like:

issuer: http://127.0.0.1:5556/dex

storage:
  type: memory

web:
  http: 0.0.0.0:5556

oauth2:
  skipApprovalScreen: true

staticClients:
  - name: TestClient
    id: client_test_id
    secret: client_test_secret
    redirectURIs:
      - http://{ip-your-app}/path/to/callback/ # example: http://localhost:5555/callback

connectors:
# mockCallback connector always returns the user 'kilgore@kilgore.trout'.
- type: mockCallback
  id: mock
  name: Mock

Now you can insert the Dex service inside your ~/.github/workflows/ci.yaml file:

[...]
jobs:
  test-oauth:
    runs-on: ubuntu-latest
    steps:
      - name: Install Dex
        run: |
          curl -L https://github.com/dexidp/dex/releases/download/v2.37.0/dex_linux_amd64 -o dex
          chmod +x dex

      - name: Start Dex Server
        run: |
          nohup ./dex serve config.yaml > dex.log 2>&1 &
          sleep 5  # Give Dex time to start
[...]

Conclusion

By following this guide, you've set up Dex as an OAuth mock server using Docker. This setup is invaluable for testing and development, allowing you to simulate OAuth flows without relying on external identity providers. For more advanced configurations and integrations, refer to the Dex documentation.

Secure a Backstage application with Docker Hardened Images

This guide shows how to secure a Backstage application using Docker Hardened Images (DHI). Backstage is a CNCF open source developer portal used by thousands of organizations to manage their software catalogs, templates, and developer tooling.

By the end of this guide, you'll have a Backstage container image that is distroless, runs as a non-root user by default, and has dramatically fewer CVEs than the standard node:24-trixie-slim base image while still supporting the native module compilation that Backstage requires.

Prerequisites

Why Backstage needs customization

The DHI migration examples cover applications where you can swap the base image and everything works. Backstage is different. It uses better-sqlite3 and other packages that compile native Node.js modules at install time, which means the build stage needs g++, make, python3, and sqlite-dev — none of which are in the base dhi.io/node image. The runtime image only needs the shared library (sqlite-libs) that the compiled native module links against.

This is a common pattern. Any Node.js application that depends on native addons (such as bcrypt, sharp, sqlite3, or node-canvas) faces the same challenge. The approach in this guide applies to all of them.

Step 1: Examine the original Dockerfile

The official Backstage documentation recommends a multi-stage Dockerfile using node:24-trixie-slim (Debian). A typical setup looks like this:

# Stage 1 - Create yarn install skeleton layer
FROM node:24-trixie-slim AS packages
WORKDIR /app
COPY backstage.json package.json yarn.lock ./
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY packages packages
COPY plugins plugins
RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 \
    -exec rm -rf {} \+

# Stage 2 - Install dependencies and build packages
FROM node:24-trixie-slim AS build
ENV PYTHON=/usr/bin/python3
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends python3 g++ build-essential && \
    rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends libsqlite3-dev && \
    rm -rf /var/lib/apt/lists/*
USER node
WORKDIR /app
COPY --from=packages --chown=node:node /app .
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn install --immutable
COPY --chown=node:node . .
RUN yarn tsc
RUN yarn --cwd packages/backend build
RUN mkdir packages/backend/dist/skeleton packages/backend/dist/bundle \
    && tar xzf packages/backend/dist/skeleton.tar.gz \
       -C packages/backend/dist/skeleton \
    && tar xzf packages/backend/dist/bundle.tar.gz \
       -C packages/backend/dist/bundle

# Stage 3 - Build the actual backend image
FROM node:24-trixie-slim
ENV PYTHON=/usr/bin/python3
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends python3 g++ build-essential && \
    rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends libsqlite3-dev && \
    rm -rf /var/lib/apt/lists/*
USER node
WORKDIR /app
COPY --from=build --chown=node:node /app/.yarn ./.yarn
COPY --from=build --chown=node:node /app/.yarnrc.yml ./
COPY --from=build --chown=node:node /app/backstage.json ./
COPY --from=build --chown=node:node /app/yarn.lock \
     /app/package.json \
     /app/packages/backend/dist/skeleton/ ./
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn workspaces focus --all --production
COPY --from=build --chown=node:node /app/packages/backend/dist/bundle/ ./
CMD ["node", "packages/backend", "--config", "app-config.yaml"]

Run this image and inspect what's available inside the container:

docker build -t backstage:init .
docker run -d \
    -e APP_CONFIG_backend_database_client='better-sqlite3' \
    -e APP_CONFIG_backend_database_connection=':memory:' \
    -e APP_CONFIG_auth_providers_guest_dangerouslyAllowOutsideDevelopment='true' \
    -p 7007:7007 \
    -u 1000 \
    --cap-drop=ALL \
    --read-only \
    --tmpfs /tmp \
    backstage:init

This works, but the runtime container has a shell, a package manager, and yarn. None of these are needed to run Backstage. Run docker exec to see what's accessible inside:

docker exec -it <container-id> sh
$ cat /etc/shells
# /etc/shells: valid login shells
/bin/sh
/usr/bin/sh
/bin/bash
/usr/bin/bash
/bin/rbash
/usr/bin/rbash
/usr/bin/dash
$ yarn --version
4.12.0
$ dpkg --version
dpkg version 1.22.11 (arm64).
$ whoami
node
$ id
uid=1000(node) gid=1000(node) groups=1000(node)

The node:24-trixie-slim image ships with three shells (dash, bash, and rbash), a package manager (dpkg), and yarn. Each of these tools increases the attack surface. An attacker who gains access to this container could use them for lateral movement across your infrastructure.

Step 2: Switch the build stages to DHI

Replace all three stages with DHI equivalents. DHI Node.js images are available in both Alpine and Debian variants. This guide uses the Alpine variant (dhi.io/node:24-alpine3.23) because it produces a smaller image. If you need to stay on Debian for compatibility reasons, use dhi.io/node:24-bookworm and keep apt-get instead of apk.

# Stage 1: prepare packages
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-dev AS packages
WORKDIR /app
COPY backstage.json package.json yarn.lock ./
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY packages packages
COPY plugins plugins
RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 \
    -exec rm -rf {} \+

# Stage 2: build the application
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-dev AS build
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
    rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=packages --chown=node:node /app .
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn install --immutable
COPY --chown=node:node . .
RUN yarn tsc
RUN yarn --cwd packages/backend build
RUN mkdir packages/backend/dist/skeleton packages/backend/dist/bundle \
    && tar xzf packages/backend/dist/skeleton.tar.gz \
       -C packages/backend/dist/skeleton \
    && tar xzf packages/backend/dist/bundle.tar.gz \
       -C packages/backend/dist/bundle

# Final Stage: create the runtime image
FROM dhi.io/node:24-alpine3.23-dev
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
    rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=build --chown=node:node /app/.yarn ./.yarn
COPY --from=build --chown=node:node /app/.yarnrc.yml ./
COPY --from=build --chown=node:node /app/backstage.json ./
COPY --from=build --chown=node:node /app/yarn.lock \
     /app/package.json \
     /app/packages/backend/dist/skeleton/ ./
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn workspaces focus --all --production \
    && rm -rf "$(yarn cache clean)"
COPY --from=build --chown=node:node /app/packages/backend/dist/bundle/ ./
CMD ["node", "packages/backend", "--config", "app-config.yaml"]

Build and tag this version:

docker build -t backstage:dhi-dev .

[!NOTE]

The -dev variant includes a shell and package manager, which is why apk add works. Backstage requires python3 and native build tools in the runtime image because yarn workspaces focus --all --production recompiles native modules during the production install. This is specific to Backstage's build process — most Node.js applications can use the standard (non-dev) DHI runtime variant without additional packages.

The DHI images come with attestations that the original node:24-trixie-slim images don't have. Check what's attached:

docker scout attest list dhi.io/node:24-alpine3.23

DHI images ship with 15 attestations including CycloneDX SBOM, SLSA provenance, OpenVEX, Scout health reports, secret scans, virus/malware reports, and an SLSA verification summary.

Step 3: Add Socket Firewall protection

DHI provides -sfw (Socket Firewall) variants for Node.js images. Socket Firewall intercepts npm and yarn commands during the build to detect and block malicious packages before they execute install scripts.

To enable Socket Firewall, change the -dev tags to -sfw-dev in all three stages. The SFW version of the Dockerfile:

# Stage 1: prepare packages
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-sfw-dev AS packages
WORKDIR /app
COPY backstage.json package.json yarn.lock ./
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY packages packages
COPY plugins plugins
RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 \
    -exec rm -rf {} \+

# Stage 2: build the packages
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-sfw-dev AS build-packages
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
    rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=packages --chown=node:node /app .
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn install --immutable
COPY --chown=node:node . .
RUN yarn tsc
RUN yarn --cwd packages/backend build
RUN mkdir packages/backend/dist/skeleton packages/backend/dist/bundle \
    && tar xzf packages/backend/dist/skeleton.tar.gz \
       -C packages/backend/dist/skeleton \
    && tar xzf packages/backend/dist/bundle.tar.gz \
       -C packages/backend/dist/bundle

# Final Stage: create the runtime image
FROM dhi.io/node:24-alpine3.23-sfw-dev
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
    rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=build-packages --chown=node:node /app/.yarn ./.yarn
COPY --from=build-packages --chown=node:node /app/.yarnrc.yml ./
COPY --from=build-packages --chown=node:node /app/backstage.json ./
COPY --from=build-packages --chown=node:node /app/yarn.lock \
     /app/package.json \
     /app/packages/backend/dist/skeleton/ ./
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn workspaces focus --all --production \
    && rm -rf "$(yarn cache clean)"
COPY --from=build-packages --chown=node:node /app/packages/backend/dist/bundle/ ./
CMD ["node", "packages/backend", "--config", "app-config.yaml"]

Build this version:

docker build -t backstage:dhi-sfw-dev .

When you build, you'll see Socket Firewall messages in the build output: Protected by Socket Firewall for any yarn and npm commands executed in the Dockerfile or in the running containers.

[!TIP]

The -sfw-dev variant is larger (1.9 GB versus 1.72 GB) because Socket Firewall adds monitoring tooling. The security benefit during yarn install outweighs the size increase.

Step 4: Remove the shell and the package manager with DHI customizations

The previous steps still use the -dev or -sfw-dev variant as the runtime image, which includes a shell and package manager. DHI customizations let you start from the base (non-dev) image — which has no shell and no package manager — and add only the runtime libraries and language runtimes your application needs.

[!IMPORTANT]

When creating a customization, only add what your application needs at runtime:

Language runtimes installed from the DHI hardened package feed are patched and tracked in the image SBOM, which is why they are acceptable as system packages. Build tools from Alpine or Debian package feeds are not hardened and should never appear in the runtime image.

For Backstage, the runtime image needs:

Docker will continuously build with SLSA Level 3 compliance and patch these customized images within the guaranteed SLA for CVE patching.

To create the customization, use one of the following methods.

{{< tabs >}} {{< tab name="Docker Hub UI" >}}

After you mirror the Node.js DHI repository to your organization's namespace:

  1. Open the mirrored Node.js repository in Docker Hub.
  2. Select Customize and choose the node:24-alpine3.23 tag.
  3. Under Packages, add sqlite-libs and python-3.14.
  4. Create the customization.

For more information, see Customize an image.

{{< /tab >}} {{< tab name="dhictl CLI" >}}

dhictl is Docker's command-line tool for managing Docker Hardened Images. It lets you browse the DHI catalog, mirror images, and create customizations directly from your terminal. You can integrate dhictl into CI/CD pipelines and infrastructure-as-code workflows. You can install dhictl as a standalone binary or as a Docker CLI plugin (docker dhi); for installation instructions, see Use the DHI CLI.

Rather than writing the customization YAML by hand, use dhictl to scaffold a starting point:

dhictl customization prepare --org YOUR_ORG node 24-alpine3.23 \
    --destination YOUR_ORG/dhi-node \
    --name "backstage" \
    --tag-suffix "_backstage" \
    --output node-backstage.yaml

Edit the generated file to add the runtime libraries:

name: backstage

source: dhi/node
tag_definition_id: node/alpine-3.23/24

destination: YOUR_ORG/dhi-node
tag_suffix: _backstage

platforms:
  - linux/amd64
  - linux/arm64

contents:
  packages:
    - sqlite-libs
    - python-3.14

accounts:
  root: true
  runs-as: node
  users:
    - name: node
      uid: 1000
  groups:
    - name: node
      gid: 1000

Then create the customization:

dhictl customization create --org YOUR_ORG node-backstage.yaml

Monitor the build progress using the customization ID from the create output. To look up the ID, run:

dhictl customization list --org YOUR_ORG

Then monitor the build:

dhictl customization build list <customization-id> --org YOUR_ORG

Docker builds the customized image on its secure infrastructure and publishes it as YOUR_ORG/dhi-node:24-alpine3.23_backstage.

[!NOTE]

If your Backstage configuration does not require Python at runtime, you can omit the python-3.14 from the packages list. The sqlite-libs package alone is sufficient to run Backstage with better-sqlite3.

{{< /tab >}} {{< /tabs >}}

Update the Dockerfile

Update only the final stage of your Dockerfile to use the customized image:

# Final Stage: create the runtime image
FROM YOUR_ORG/dhi-node:24-alpine3.23_backstage
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/packages/backend/dist/bundle/ ./
CMD ["node", "packages/backend", "--config", "app-config.yaml"]

Build this version:

docker build -t backstage:dhi .

Since the customization includes only runtime libraries and OCI artifacts — no build tools, no package manager, no shell — the resulting image is distroless:

docker run --rm YOUR_ORG/dhi-node:24-alpine3.23_backstage sh -c "echo hello"
docker: Error response from daemon: ... exec: "sh": executable file not found in $PATH

With the Enterprise customization:

Confirm the container no longer has shell access:

docker exec -it <container-id> sh
OCI runtime exec failed: exec failed: unable to start container process: ...

Use Docker Debug if you need to troubleshoot a running distroless container.

[!NOTE]

If your organization requires FIPS/STIG compliant images, that's also an option in DHI Enterprise.

Step 5: Verify the results

Compare the DHI-based image against the original using Docker Scout:

docker scout compare backstage:dhi \
    --to backstage:init \
    --platform linux/amd64 \
    --ignore-unchanged

A typical comparison across the approaches shows results similar to the following:

Metric Original DHI -dev DHI -sfw-dev Enterprise
Disk usage 1.61 GB 1.72 GB 1.9 GB 1.49 GB
Content size 268 MB 288 MB 328 MB 247 MB
Shell in runtime Yes Yes Yes No
Package manager Yes Yes Yes No
Non-root default No No No Yes
Socket Firewall No No Yes (build) Yes (build) / No (runtime)
SLSA provenance No Base only Base only Full (Level 3)

[!NOTE]

The -sfw-dev variant is larger because Socket Firewall adds monitoring tooling to the image. The additional size is in the build stages, and the security benefit during yarn install outweighs the size increase.

For a more thorough assessment, scan with multiple tools:

trivy image backstage:dhi
grype backstage:dhi
docker scout quickview backstage:dhi

Different scanners detect different issues. Running all three gives you the most complete view of your security posture.

What's next

Use Docker Hardened Images with Red Hat OpenShift

Docker Hardened Images (DHI) can be deployed on Red Hat OpenShift Container Platform, but OpenShift’s security model differs from standard Kubernetes in ways that require specific configuration. Because OpenShift runs containers with an arbitrarily assigned user ID rather than the image’s default, you must adjust file ownership and group permissions in your Dockerfiles to ensure writable paths remain accessible.

This guide explains how to deploy Docker Hardened Images in OpenShift environments, covering Security Context Constraints (SCCs), arbitrary user ID assignment, file permission requirements, and best practices for both runtime and development image variants.

How OpenShift security differs from Kubernetes

OpenShift extends Kubernetes with Security Context Constraints (SCCs), which control what actions a pod can perform and what resources it can access. While vanilla Kubernetes uses Pod Security Standards (PSS) for similar purposes, SCCs are more granular and enforced by default.

The key differences that affect DHI deployments:

Arbitrary user IDs. By default, OpenShift runs containers using an arbitrarily assigned user ID (UID) from a range allocated to each project. The default restricted-v2 SCC (introduced in OpenShift 4.11) uses the MustRunAsRange strategy, which overrides the USER directive in the container image with a UID from the project’s allocated range (typically starting higher than 1000000000). This means even though a DHI image specifies a non-root user (UID 65532), OpenShift will run the container as a different, unpredictable UID.

Root group requirement. OpenShift assigns the arbitrary UID to the root group (GID 0). The container process always runs with gid=0(root). Any directories or files that the process needs to write to must be owned by the root group (GID 0) with group read/write permissions. This is documented in the Red Hat guidelines for creating images.

[!IMPORTANT]

DHI images set file ownership to nonroot:nonroot (65532:65532) by default. Because the OpenShift arbitrary UID is NOT in the nonroot group (65532), it cannot write to those files — even though the pod is admitted by the SCC and the container starts. You must change group ownership to GID 0 for any writable path. This is the most common source of permission errors when deploying DHI on OpenShift.

Capability restrictions. The restricted-v2 SCC drops all Linux capabilities by default and enforces allowPrivilegeEscalation: false, runAsNonRoot: true, and a seccompProfile of type RuntimeDefault. DHI runtime images already satisfy these constraints because they run as a non-root user and don’t require elevated capabilities.

Pull DHI images into OpenShift

Before deploying, create an image pull secret so your OpenShift cluster can authenticate to the DHI registry or your mirrored repository on Docker Hub.

Create an image pull secret

oc create secret docker-registry dhi-pull-secret \
    --docker-server=docker.io \
    --docker-username=<your-docker-username> \
    --docker-password=<your-docker-access-token> \
    --docker-email=<your-email>

If you’re pulling directly from dhi.io instead of a mirrored repository, set --docker-server=dhi.io.

To use the secret with a specific service account instead:

Build OpenShift-compatible images from DHI

DHI runtime images are distroless — they contain no shell, no package manager, and no RUN-capable environment. This means you cannot use RUN commands in the runtime stage of your Dockerfile. All file permission adjustments for OpenShift must happen in the -dev build stage, and the results must be copied into the runtime stage using COPY --chown.

The core pattern for OpenShift compatibility:

  1. Use a DHI -dev variant as the build stage (it has a shell).
  2. Build your application and set GID 0 ownership in the build stage.
  3. Copy the results into the DHI runtime image using COPY --chown=<UID>:0.

Example: Nginx for OpenShift

# Build stage — has a shell, can run commands
FROM YOUR_ORG/dhi-nginx:1.29-alpine3.23-dev AS build

# Copy custom config and set root group ownership
COPY nginx.conf /tmp/nginx.conf
COPY default.conf /tmp/default.conf

# Prepare writable directories with GID 0
# (Nginx needs to write to cache, logs, and PID file locations)
RUN mkdir -p /tmp/nginx-cache /tmp/nginx-run && \
    chgrp -R 0 /tmp/nginx-cache /tmp/nginx-run && \
    chmod -R g=u /tmp/nginx-cache /tmp/nginx-run

# Runtime stage — distroless, NO shell, NO RUN commands
FROM YOUR_ORG/dhi-nginx:1.29-alpine3.23

COPY --from=build --chown=65532:0 /tmp/nginx.conf /etc/nginx/nginx.conf
COPY --from=build --chown=65532:0 /tmp/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build --chown=65532:0 /tmp/nginx-cache /var/cache/nginx
COPY --from=build --chown=65532:0 /tmp/nginx-run /var/run

[!IMPORTANT]

Always use --chown=<UID>:0 (user:root-group) when copying files into the runtime stage. This ensures the arbitrary UID that OpenShift assigns can access the files through root group membership. Never use RUN in the runtime stage — distroless DHI images have no shell.

[!NOTE]

The UID for DHI images varies by image. Most use 65532 (nonroot), but some (like the Node.js image) may use a different UID. Verify with: docker inspect dhi.io/<image>:<tag> --format '{{.Config.User}}'

Deploy to OpenShift:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-dhi
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-dhi
  template:
    metadata:
      labels:
        app: nginx-dhi
    spec:
      containers:
        - name: nginx
          image: YOUR_ORG/dhi-nginx:1.29-alpine3.23
          ports:
            - containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            runAsNonRoot: true
            seccompProfile:
              type: RuntimeDefault
            capabilities:
              drop:
                - ALL
      imagePullSecrets:
        - name: dhi-pull-secret

DHI Nginx listens on port 8080 by default (not 80), which is compatible with the non-root requirement. No SCC changes are needed.

Example: Node.js application for OpenShift

# Build stage — dev variant has shell and npm
FROM YOUR_ORG/dhi-node:24-alpine3.23-dev AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Set GID 0 on everything the runtime needs to write
RUN chgrp -R 0 /app/dist /app/node_modules && \
    chmod -R g=u /app/dist /app/node_modules

# Runtime stage — distroless, NO shell
FROM YOUR_ORG/dhi-node:24-alpine3.23
WORKDIR /app
COPY --from=build --chown=65532:0 /app/dist ./dist
COPY --from=build --chown=65532:0 /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

Deploy:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: node-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: node-app
  template:
    metadata:
      labels:
        app: node-app
    spec:
      containers:
        - name: app
          image: YOUR_ORG/dhi-node-app:latest
          ports:
            - containerPort: 3000
          securityContext:
            allowPrivilegeEscalation: false
            runAsNonRoot: true
            seccompProfile:
              type: RuntimeDefault
            capabilities:
              drop:
                - ALL
      imagePullSecrets:
        - name: dhi-pull-secret

Handle arbitrary user IDs

OpenShift’s restricted-v2 SCC assigns a random UID to the container process. This UID won’t exist in /etc/passwd inside the image, but the container will still run — the process just won’t have a username associated with it.

This can cause issues with applications that:

Add a passwd entry for the arbitrary UID

Some applications (notably those using certain Python or Java libraries) require a valid /etc/passwd entry for the running user. You can handle this with a wrapper entrypoint script.

Because this pattern requires a shell, it only works with DHI -dev variants or with a DHI Enterprise customized image that includes a shell. Prepare the image in the build stage:

FROM YOUR_ORG/dhi-python:3.13-alpine3.23-dev AS build
# ... build your application ...

# Make /etc/passwd group-writable so the entrypoint can append to it
RUN chgrp 0 /etc/passwd && chmod g=u /etc/passwd

# Create the entrypoint wrapper
RUN printf '#!/bin/sh\n\
if ! whoami > /dev/null 2>&1; then\n\
  if [ -w /etc/passwd ]; then\n\
    echo "${USER_NAME:-appuser}:x:$(id -u):0:dynamic user:/tmp:/sbin/nologin" >> /etc/passwd\n\
  fi\n\
fi\n\
exec "$@"\n' > /entrypoint.sh && chmod +x /entrypoint.sh

# This pattern requires a -dev variant as runtime (has shell)
FROM YOUR_ORG/dhi-python:3.13-alpine3.23-dev
COPY --from=build --chown=65532:0 /app ./app
COPY --from=build --chown=65532:0 /entrypoint.sh /entrypoint.sh
COPY --from=build --chown=65532:0 /etc/passwd /etc/passwd
USER 65532
ENTRYPOINT ["/entrypoint.sh"]
CMD ["python", "app/main.py"]

[!NOTE]

For distroless runtime images (no shell), the passwd-injection pattern is not possible. Instead, use the nonroot SCC (described in the following section) to run with the image’s built-in UID so the existing /etc/passwd entry matches the running process. Alternatively, OpenShift 4.x automatically injects the arbitrary UID into /etc/passwd in most cases, which resolves this for many applications.

Use the non-root SCC for fixed UIDs

If your application requires running as the specific UID defined in the image (typically 65532 for DHI), you can use the nonroot SCC instead of the default restricted-v2. The nonroot SCC uses the MustRunAsNonRoot strategy, which allows any non-zero UID.

[!IMPORTANT]

For the nonroot SCC to work, the image’s USER directive must specify a numeric UID (for example, 65532), not a username string like nonroot. OpenShift cannot verify that a username maps to a non-zero UID. Verify your DHI image with: docker inspect YOUR_ORG/dhi-node:24-alpine3.23 --format '{{.Config.User}}' If the output is a string rather than a number, set runAsUser explicitly in the pod spec.

Create a service account and grant it the nonroot SCC:

oc create serviceaccount dhi-nonroot
oc adm policy add-scc-to-user nonroot -z dhi-nonroot

Reference the service account in your deployment:

spec:
  template:
    spec:
      serviceAccountName: dhi-nonroot
      containers:
        - name: app
          image: YOUR_ORG/dhi-node:24-alpine3.23
          securityContext:
            runAsUser: 65532
            runAsNonRoot: true
            allowPrivilegeEscalation: false
            seccompProfile:
              type: RuntimeDefault
            capabilities:
              drop:
                - ALL

Verify the SCC assignment after deployment:

oc get pod <pod-name> -o jsonpath='{.metadata.annotations.openshift\.io/scc}'

This should return nonroot.

When using the nonroot SCC with a fixed UID, the process runs as 65532 (matching the image’s file ownership), so the GID 0 adjustments are not strictly required for paths already owned by 65532. However, applying chown <UID>:0 is still recommended for portability across both restricted-v2 and nonroot SCCs.

Use DHI dev variants in OpenShift

DHI -dev variants include a shell, package manager, and development tools. They run as root (UID 0) by default, which conflicts with OpenShift’s restricted-v2 SCC. There are three approaches:

Option 1: Use dev variants only in build stages (recommended)

Use -dev variants only in Dockerfile build stages and never deploy them directly to OpenShift:

FROM YOUR_ORG/dhi-node:24-alpine3.23-dev AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Set root group ownership for OpenShift compatibility
RUN chgrp -R 0 /app/dist /app/node_modules && \
    chmod -R g=u /app/dist /app/node_modules

FROM YOUR_ORG/dhi-node:24-alpine3.23
WORKDIR /app
COPY --from=build --chown=65532:0 /app/dist ./dist
COPY --from=build --chown=65532:0 /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

The final runtime image is non-root and distroless, fully compatible with restricted-v2.

Option 2: Grant the anyuid SCC for debugging

If you need to run a -dev variant directly in OpenShift for debugging, grant the anyuid SCC to a dedicated service account:

oc create serviceaccount dhi-debug
oc adm policy add-scc-to-user anyuid -z dhi-debug

Then reference it in your pod:

apiVersion: v1
kind: Pod
metadata:
  name: dhi-debug
spec:
  serviceAccountName: dhi-debug
  containers:
    - name: debug
      image: YOUR_ORG/dhi-node:24-alpine3.23-dev
      command: ["sleep", "infinity"]
  imagePullSecrets:
    - name: dhi-pull-secret

[!IMPORTANT]

The anyuid SCC allows running as any UID including root. Only use this for temporary debugging — never in production workloads.

Option 3: Use oc debug or ephemeral containers

For distroless runtime images with no shell, use OpenShift-native debugging tools instead of docker debug (which only works with Docker Engine, not with CRI-O on OpenShift).

Use oc debug to create a copy of a pod with a debug shell:

# Create a debug pod based on a deployment
oc debug deployment/nginx-dhi

# Override the image to use a -dev variant with a shell
oc debug deployment/nginx-dhi --image=YOUR_ORG/dhi-node:24-alpine3.23-dev

Use ephemeral containers (OpenShift 4.12+ / Kubernetes 1.25+):

kubectl debug -it <pod-name> --image=YOUR_ORG/dhi-node:24-alpine3.23-dev \
    --target=app -- sh

This attaches a temporary debug container to a running pod without restarting it, sharing the pod’s process namespace.

[!NOTE]

docker debug is a Docker Desktop/CLI feature for local development. It is not available on OpenShift clusters, which use CRI-O as their container runtime.

Deploy DHI Helm charts on OpenShift

DHI provides pre-configured Helm charts for popular applications. When deploying these charts on OpenShift, you may need to adjust security context settings.

Inspect chart values first

Before installing, check what security context values the chart exposes:

helm registry login dhi.io

helm show values oci://dhi.io/<chart-name> --version <version> | grep -A 20 securityContext

The available value paths vary by chart, so always check values.yaml before setting overrides.

Install with OpenShift overrides

The following example shows a typical installation pattern. Adjust the --set paths based on what helm show values returns for your specific chart:

helm install my-release oci://dhi.io/<chart-name> \
    --version <version> \
    --set "imagePullSecrets[0].name=dhi-pull-secret" \
    -f openshift-values.yaml

Create an openshift-values.yaml with security context overrides appropriate for your chart:

# Example — adjust keys based on `helm show values` output
podSecurityContext:
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  capabilities:
    drop:
      - ALL

[!NOTE]

DHI Helm chart value paths are not standardized across charts. For example, one chart may use image.imagePullSecrets, while another uses global.imagePullSecrets. Always consult the specific chart’s documentation or values.yaml.

Verify your deployment

After deploying a DHI image to OpenShift, verify the security configuration.

Check the assigned SCC

oc get pods -o 'custom-columns=NAME:.metadata.name,SCC:.metadata.annotations.openshift\.io/scc'

Runtime DHI images should show restricted-v2 (or nonroot if you configured it).

Check the running UID

oc exec <pod-name> -- id

With the restricted-v2 SCC, you should see output like:

uid=1000650000 gid=0(root) groups=0(root),1000650000

The UID is from the project’s allocated range, and the primary GID is always 0 (root group). With the nonroot SCC and runAsUser: 65532, you would see uid=65532.

Confirm the image is distroless

oc exec <pod-name> -- sh -c "echo hello"

For runtime (non-dev) DHI images, this command should fail with an error indicating that sh was not found in $PATH. The exact error format varies between CRI-O versions.

Scan the deployed image

Use Docker Scout to verify the security posture of the deployed image (run this from your local machine, not on the cluster):

docker scout cves YOUR_ORG/dhi-nginx:1.29-alpine3.23
docker scout quickview YOUR_ORG/dhi-nginx:1.29-alpine3.23

Common issues and solutions

Pod fails to start with “container has runAsNonRoot and image has group or user ID set to root.” This happens when deploying a DHI -dev variant with the default restricted-v2 SCC. Either use the runtime variant instead, or grant the anyuid SCC to the service account.

Application cannot write to a directory. The arbitrary UID assigned by OpenShift doesn’t have write permissions. This is the most common issue with DHI on OpenShift. All writable paths must be owned by GID 0 with group write permissions. Fix this in the build stage: chgrp -R 0 /path && chmod -R g=u /path, then COPY --chown=<UID>:0 into the runtime stage.

Application fails with “user not found” or “no matching entries in passwd file.” Some applications require a valid /etc/passwd entry. OpenShift 4.x automatically injects the arbitrary UID into /etc/passwd in most cases. If your application still fails, use the passwd-injection pattern (requires a -dev variant) or use the nonroot SCC to run with the image’s built-in UID.

Pod fails to bind to port 80 or 443. Ports lower than 1024 require root privileges. DHI images use unprivileged ports by default (for example, Nginx uses 8080). Configure your OpenShift Service to map the external port to the container’s unprivileged port:

apiVersion: v1
kind: Service
metadata:
  name: nginx-dhi
spec:
  ports:
    - port: 80
      targetPort: 8080
  selector:
    app: nginx-dhi

ImagePullBackOff with “unauthorized: authentication required.” Verify the pull secret is correctly configured and linked to the service account. Check with oc get secret dhi-pull-secret and oc describe sa default.

Dockerfile build fails with “exec: not found” in runtime stage. You are using RUN in a distroless runtime stage. DHI runtime images have no shell, so RUN commands cannot execute. Move all RUN commands to the -dev build stage and use COPY --chown to transfer results.

DHI and OpenShift compatibility summary

Feature DHI runtime DHI -dev DHI with Enterprise customization
Default SCC (restricted-v2) Yes, with GID 0 permissions Requires anyuid Yes, with GID 0 permissions
Non-root by default Yes (UID 65532) No (root) Yes (configurable UID)
Arbitrary UID support Yes, with chown <UID>:0 Yes Yes, with chown <UID>:0
Distroless (no shell) Yes — no RUN in Dockerfile No Yes — no RUN in Dockerfile
Unprivileged ports Yes (higher than 1024) Configurable Yes (higher than 1024)
SLSA Build Level 3 Yes Yes Yes
Debug on cluster oc debug / ephemeral containers oc exec with shell oc debug / ephemeral containers

What’s next

Explore VEX statements in Docker Hardened Images

Standard vulnerability scanners report CVEs against packages present in an image. With Docker Hardened Images, those packages are there by design, but each reported CVE has a VEX statement explaining whether it is exploitable in this specific product configuration. This guide walks through scanning a Docker Hardened Image with and without VEX and auditing the justification behind every suppression.

Prerequisites

Expose daemon for Windows users

The -v /var/run/docker.sock:/var/run/docker.sock socket mount used in the containerized scanner commands throughout this guide does not work on Docker Desktop for Windows. To use containerized scanners on Windows, go to Settings > General in Docker Desktop and turn on Expose daemon on tcp://localhost:2375 without TLS. Then replace -v /var/run/docker.sock:/var/run/docker.sock with -e DOCKER_HOST=tcp://host.docker.internal:2375 in every containerized scanner command.

[!WARNING]

Exposing the daemon on TCP without TLS makes your system vulnerable to remote code execution attacks. Turn off the setting when you are done testing.

Step 1: Scan without VEX

Sign in to the Docker Hardened Images registry:

$ docker login dhi.io

Then pull the image:

$ docker pull dhi.io/python:3.13

Then scan without VEX to see the raw CVE count. Docker Scout automatically applies VEX on Docker Hardened Images. To see the unfiltered CVE baseline, use Trivy or Grype.

{{< tabs >}} {{< tab name="Trivy" >}}

$ trivy image --scanners vuln dhi.io/python:3.13

If Trivy isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy:latest image --scanners vuln dhi.io/python:3.13

Example output:

Total: 30 (UNKNOWN: 0, LOW: 15, MEDIUM: 11, HIGH: 4, CRITICAL: 0)

{{< /tab >}} {{< tab name="Grype" >}}

$ grype dhi.io/python:3.13

If Grype isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  anchore/grype:latest docker:dhi.io/python:3.13

Example output:

NAME          INSTALLED              FIXED IN     TYPE  VULNERABILITY       SEVERITY
libc6         2.41-12+deb13u2                     deb   CVE-2018-20796      Negligible
libc6         2.41-12+deb13u2        (won't fix)  deb   CVE-2026-4437       High
libc6         2.41-12+deb13u2        (won't fix)  deb   CVE-2026-5450       Critical
...

{{< /tab >}} {{< /tabs >}}

The output lists CVEs across libc6, libncursesw6, libsqlite3-0, libuuid1, zlib1g, and others, all runtime dependencies that Python needs to function. These packages are present by design.

A scan result like this doesn't mean every reported CVE requires patching. It means these CVEs have been reported against packages present in the image. Whether any of those CVEs are actually exploitable in this configuration is a separate question, and that's exactly what VEX answers.

Step 2: Fetch the VEX attestation

Export the VEX attestation to a local file:

$ docker scout vex get registry://dhi.io/python:3.13 --output python-vex.json

The registry:// prefix tells Scout to fetch the attestation from the registry rather than the local image store. Because you pulled the image in Step 1, it already exists locally, and without this prefix Scout would find no attestation there.

This fetches a signed OpenVEX document from registry.scout.docker.com, Docker's supply chain metadata registry for all Docker Hardened Images. The document records Docker's exploitability assessment for every CVE found in the image's SBOM.

[!NOTE]

Docker Scout fetches this file automatically when scanning. You only need to download it explicitly for scanners that don't natively integrate it, or to run the jq queries in Steps 5 and 6.

Step 3: Scan with VEX applied

{{< tabs >}} {{< tab name="Docker Scout" >}}

Docker Scout automatically fetches and applies the VEX attestation with no local file needed:

$ docker scout cves dhi.io/python:3.13

Example output:

    ✓ SBOM obtained from attestation, 47 packages indexed
    ✓ Provenance obtained from attestation
    ✓ VEX statements obtained from attestation
    ✓ No vulnerable package detected

{{< /tab >}} {{< tab name="Trivy" >}}

Pass the VEX file with the --vex flag:

$ trivy image --scanners vuln --vex python-vex.json dhi.io/python:3.13

If Trivy isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$(pwd)/python-vex.json:/tmp/vex.json" \
  aquasec/trivy:latest image --scanners vuln --vex /tmp/vex.json dhi.io/python:3.13

Example output:

Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

Some vulnerabilities have been ignored/suppressed. Use the '--show-suppressed' flag to display them.

{{< /tab >}} {{< tab name="Grype" >}}

Pass the VEX file with the --vex flag:

$ grype dhi.io/python:3.13 --vex python-vex.json

If Grype isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$(pwd)/python-vex.json:/tmp/vex.json" \
  anchore/grype:latest docker:dhi.io/python:3.13 --vex /tmp/vex.json

Example output:

No vulnerabilities found

{{< /tab >}} {{< /tabs >}}

Same image, same packages, same CVE database. The only difference is context. The scanner matched each CVE against the VEX file and suppressed every one that Docker assessed as not exploitable.

The packages are still there. Check the SBOM and you will see libc6, libsqlite3-0, and every other package from Step 1. Zero CVEs does not mean the packages were removed. It means each reported CVE has a documented reason why it does not apply to this product configuration.

VEX is an open standard: the attestation travels with the image and any compliant scanner reads the same reasoning.

Step 4: Inspect every suppression and its justification

Docker Scout and Grype suppress VEX-matched CVEs but do not surface the justification code in their output. Use Trivy's --show-suppressed flag to see every suppressed CVE alongside its per-CVE justification code.

$ trivy image --scanners vuln --vex python-vex.json --show-suppressed dhi.io/python:3.13

If Trivy isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$(pwd)/python-vex.json:/tmp/vex.json" \
  aquasec/trivy:latest image --scanners vuln --vex /tmp/vex.json --show-suppressed dhi.io/python:3.13

Example output:

Suppressed Vulnerabilities (Total: 28)
======================================
┌──────────────┬──────────────────┬──────────┬──────────────┬───────────────────────────────────────────────────┐
│   Library    │  Vulnerability   │ Severity │    Status    │                     Statement                     │
├──────────────┼──────────────────┼──────────┼──────────────┼───────────────────────────────────────────────────┤
│ libc6        │ CVE-2010-4756    │ LOW      │ not_affected │ vulnerable_code_cannot_be_controlled_by_adversary │
│ libsqlite3-0 │ CVE-2025-70873   │ LOW      │ not_affected │ vulnerable_code_not_present                       │
│ ...          │ ...              │ ...      │ ...          │ ...                                               │
└──────────────┴──────────────────┴──────────┴──────────────┴───────────────────────────────────────────────────┘

The Statement column shows the machine-readable justification code from the VEX file.

The justification codes have precise meanings:

For the full list of justification codes, see VEX status reference.

Every suppression is documented, auditable, and verifiable with any VEX-enabled scanner.

Step 5: Read Docker's reasoning for a specific CVE

The justification codes are machine-readable; the status_notes field in the VEX file contains Docker's human-readable reasoning. Use jq to look up a specific CVE:

$ jq '.statements[] | select(.vulnerability.name == "CVE-2010-4756") | {status, justification, status_notes}' python-vex.json

Example output:

{
  "status": "not_affected",
  "justification": "vulnerable_code_cannot_be_controlled_by_adversary",
  "status_notes": "Standard POSIX behavior in glibc. Applications using glob need to impose limits themselves. Requires authenticated access and is considered unimportant by Debian."
}

The status_notes field explains Docker's reasoning in plain language. For CVE-2010-4756, the glob behavior described by the CVE is standard POSIX behavior, requires authenticated access, and is classified as unimportant by the Debian security team.

Each statement also lists the affected products as Package URLs (PURLs), for example pkg:deb/debian/glibc@2.41-12%2Bdeb13u2?os_distro=trixie&os_name=debian&os_version=13. Trivy matched this statement to libc6 in the image's SBOM by comparing that PURL against the packages recorded in the SBOM.

[!IMPORTANT]

PURL matching is strict. Scanners must match VEX statements to packages using the full PURL string, including the os_name, os_version, and os_distro qualifiers. Matching on package name alone risks applying a suppression from one OS version to a different version where the CVE is exploitable.

Step 6: Filter VEX statements by status

Once you have python-vex.json, use jq to query it directly.

Count statements by status:

$ jq '[.statements[].status] | group_by(.) | map({status: .[0], count: length})' python-vex.json

List all CVEs under active investigation:

$ jq '[.statements[] | select(.status == "under_investigation") | {cve: .vulnerability.name, products: [.products[]."@id"]}]' python-vex.json

List any CVEs with affected status:

$ jq '[.statements[] | select(.status == "affected") | {cve: .vulnerability.name, action: .action_statement}]' python-vex.json

The affected query returns an empty array for the current dhi.io/python:3.13 image, which is the expected result for an actively maintained tag. To see affected entries across all DHI Python versions, query the full VEX feed:

$ curl -s https://raw.githubusercontent.com/docker-hardened-images/advisories/main/vex/python/dhi-python.vex.json \
  | jq '[.statements[] | select(.status == "affected") | {cve: .vulnerability.name, action: .action_statement}]'

For status definitions and justification codes, see the VEX status reference.

What's next

Containerize a Django application

[!TIP]

If you're new to Docker, start with the Docker basics guide to get familiar with key concepts like images, containers, and Dockerfiles.


Overview

This guide walks you through containerizing a Django application with Docker. By the end, you will:


Create the Django project

You can bootstrap the project with a local uv installation, or entirely inside a container using the same DHI image the Dockerfile uses, with no local Python required.

{{< tabs >}} {{< tab name="Local (uv)" >}}

  1. Initialize the project pinned to Python 3.14, then navigate into it:

    $ uv init --python 3.14 django-docker
    $ cd django-docker
    
  2. Add Django and Gunicorn, then scaffold the Django project:

    $ uv add django gunicorn
    $ uv run django-admin startproject myapp .
    

{{< /tab >}} {{< tab name="Container (DHI)" >}}

The DHI dev image already has Python 3.14, so the bootstrapped project will match the Dockerfile exactly.

  1. Create the project directory and navigate into it:

    $ mkdir django-docker && cd django-docker
    
  2. Initialize the project, add dependencies, and scaffold. All in one container run:

    $ docker run --rm -v $PWD:$PWD -w $PWD \
      -e UV_LINK_MODE=copy \
      dhi.io/python:3.14-alpine3.23-dev \
      sh -c "pip install --quiet --root-user-action=ignore uv && uv init --name django-docker --python 3.14 . && uv add django gunicorn && uv run django-admin startproject myapp ."
    

    [!NOTE]

    The previous command uses Mac/Linux shell syntax. On Windows, adjust the path: PowerShell uses ${PWD}, Command Prompt uses %cd%, Git Bash requires MSYS_NO_PATHCONV=1 with $(pwd -W).

{{< /tab >}} {{< /tabs >}}

Your directory should now contain the following files:

├── .python-version
├── main.py
├── manage.py
├── myapp/
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── pyproject.toml
├── uv.lock
└── README.md

Create a production Dockerfile

Docker Hardened Images are production-ready base images maintained by Docker that minimize attack surface. For more details, see Docker Hardened Images.

  1. Sign in to the DHI registry:

    $ docker login dhi.io
    
  2. Create a .dockerignore file to exclude local artifacts from the build context:

    .venv/
    __pycache__/
    *.pyc
    .git/
    
  3. Create a Dockerfile with the following contents:

    # syntax=docker/dockerfile:1
    
    # Build stage: the -dev image includes tools needed to install packages.
    FROM dhi.io/python:3.14-alpine3.23-dev AS builder
    
    # Prevent Python from writing .pyc files to disk.
    ENV PYTHONDONTWRITEBYTECODE=1
    # Prevent Python from buffering stdout/stderr so logs appear immediately.
    ENV PYTHONUNBUFFERED=1
    
    RUN pip install --quiet --root-user-action=ignore uv
    # Use copy mode since the cache and build filesystem are on different volumes.
    ENV UV_LINK_MODE=copy
    
    WORKDIR /app
    
    # Install dependencies into a virtual environment using cache and bind mounts
    # so neither uv nor the lock files need to be copied into the image.
    RUN --mount=type=cache,target=/root/.cache/uv \
        --mount=type=bind,source=uv.lock,target=uv.lock \
        --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
        uv sync --frozen --no-install-project
    
    # Runtime stage: minimal DHI image with no shell or package manager,
    # already runs as the nonroot user.
    FROM dhi.io/python:3.14-alpine3.23
    
    # Prevent Python from buffering stdout/stderr so logs appear immediately.
    ENV PYTHONUNBUFFERED=1
    # Activate the virtual environment copied from the build stage.
    ENV PATH="/app/.venv/bin:$PATH"
    
    WORKDIR /app
    
    # Copy the pre-built virtual environment and application source code.
    COPY --from=builder /app/.venv /app/.venv
    COPY . .
    
    EXPOSE 8000
    
    # Run Gunicorn as the production WSGI server.
    CMD ["gunicorn", "myapp.wsgi:application", "--bind", "0.0.0.0:8000"]
    
  4. Create a compose.yaml file:

    services:
      web:
        build: .
        ports:
          - "8000:8000"
    

Run the application

From the django-docker directory, run:

$ docker compose up --build

Open a browser and navigate to http://localhost:8000. You should see the Django welcome page.

Press ctrl+c to stop the application.


Set up a development environment

The production setup uses Gunicorn and requires a full image rebuild to pick up code changes. For development, you can add a development stage to your Dockerfile that uses Django's built-in server, and configure Compose Watch to automatically sync code changes into the running container without a rebuild.

Update the Dockerfile

Replace your Dockerfile with a multi-stage version that adds a development stage alongside production:

# syntax=docker/dockerfile:1

# Build stage: the -dev image includes tools needed to install packages.
FROM dhi.io/python:3.14-alpine3.23-dev AS builder

# Prevent Python from writing .pyc files to disk.
ENV PYTHONDONTWRITEBYTECODE=1
# Prevent Python from buffering stdout/stderr so logs appear immediately.
ENV PYTHONUNBUFFERED=1

RUN pip install --quiet --root-user-action=ignore uv
# Use copy mode since the cache and build filesystem are on different volumes.
ENV UV_LINK_MODE=copy

WORKDIR /app

# Install dependencies into a virtual environment using cache and bind mounts
# so neither uv nor the lock files need to be copied into the image.
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-install-project

# The development stage inherits the -dev image and virtual environment from
# the builder. Django's built-in server reloads when Compose Watch syncs files.
FROM builder AS development

ENV PATH="/app/.venv/bin:$PATH"

COPY . .
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

# The production stage uses the minimal runtime image, which has no shell,
# no package manager, and already runs as the nonroot user.
FROM dhi.io/python:3.14-alpine3.23 AS production

# Prevent Python from buffering stdout/stderr so logs appear immediately.
ENV PYTHONUNBUFFERED=1
# Activate the virtual environment copied from the build stage.
ENV PATH="/app/.venv/bin:$PATH"

WORKDIR /app

# Copy only the pre-built virtual environment and application source code.
COPY --from=builder /app/.venv /app/.venv
COPY . .

EXPOSE 8000

# Run Gunicorn as the production WSGI server.
CMD ["gunicorn", "myapp.wsgi:application", "--bind", "0.0.0.0:8000"]

Update the Compose file

Replace your compose.yaml with the following. It targets the development stage, adds a PostgreSQL database, and configures Compose Watch:

services:
  web:
    build:
      context: .
      # Build the development stage from the multi-stage Dockerfile.
      target: development
    ports:
      - "8000:8000"
    environment:
      # Enable Django's verbose debug error pages (the dev server always auto-reloads).
      - DEBUG=1
      # Database connection settings passed to Django via environment variables.
      - POSTGRES_DB=myapp
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
      - POSTGRES_HOST=db
      - POSTGRES_PORT=5432
    # Wait for the database to pass its healthcheck before starting the web service.
    depends_on:
      db:
        condition: service_healthy
    develop:
      watch:
        # Sync source file changes directly into the container so Django's
        # dev server can reload them without a full image rebuild.
        - action: sync
          path: .
          target: /app
          ignore:
            - __pycache__/
            - "*.pyc"
            - .git/
            - .venv/
        # Rebuild the image when dependencies change.
        - action: rebuild
          path: pyproject.toml
        - action: rebuild
          path: uv.lock
  db:
    image: dhi.io/postgres:18
    restart: always
    volumes:
      # Persist database data across container restarts.
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=myapp
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
    # Expose the port only to other services on the Compose network,
    # not to the host machine.
    expose:
      - 5432
    # Only report healthy once PostgreSQL is ready to accept connections,
    # so the web service doesn't start before the database is available.
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:

The sync action pushes file changes directly into the running container so Django's dev server reloads them automatically. A change to pyproject.toml or uv.lock triggers a full image rebuild instead.

[!NOTE]

To learn more about Compose Watch, see Use Compose Watch.

Add the PostgreSQL driver

Add the psycopg adapter to your project:

{{< tabs >}} {{< tab name="Local (uv)" >}}

$ uv add 'psycopg[binary]'

{{< /tab >}} {{< tab name="Container (DHI)" >}}

$ docker run --rm -v $PWD:$PWD -w $PWD \
  -e UV_LINK_MODE=copy \
  dhi.io/python:3.14-alpine3.23-dev \
  sh -c "pip install --quiet --root-user-action=ignore uv && uv add 'psycopg[binary]'"

{{< /tab >}} {{< /tabs >}}

Then update myapp/settings.py to read DEBUG and DATABASES from environment variables:

import os

DEBUG = os.environ.get('DEBUG', '0') == '1'

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("POSTGRES_DB", "myapp"),
        "USER": os.environ.get("POSTGRES_USER", "postgres"),
        "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "password"),
        "HOST": os.environ.get("POSTGRES_HOST", "localhost"),
        "PORT": os.environ.get("POSTGRES_PORT", "5432"),
    }
}

Run with Compose Watch

Start the development stack:

$ docker compose watch

Open a browser and navigate to http://localhost:8000.

Try editing a file, for example add a view to myapp/views.py. Compose Watch syncs the change into the container and Django's dev server reloads automatically. If you update pyproject.toml or uv.lock, Compose Watch triggers a full image rebuild.

Press ctrl+c to stop.


Summary

In this guide, you:

.NET language-specific guide

The .NET getting started guide teaches you how to create a containerized .NET application using Docker. In this guide, you'll learn how to:

After completing the .NET getting started modules, you should be able to containerize your own .NET application based on the examples and instructions provided in this guide.

Start by containerizing an existing .NET application.

Containerize a .NET application

Prerequisites

Overview

This section walks you through containerizing and running a .NET application.

Get the sample applications

In this guide, you will use a pre-built .NET application. The application is similar to the application built in the Docker Blog article, Building a Multi-Container .NET App Using Docker Desktop.

Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository.

$ git clone https://github.com/docker/docker-dotnet-sample

Create Docker assets

Now that you have an application, you can create the necessary Docker assets to containerize it. You can choose between using the official .NET images or Docker Hardened Images (DHI).

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

Docker Hardened Images (DHIs) are minimal, secure, and production-ready container base and application images maintained by Docker. DHI images are recommended for better security—they are designed to reduce vulnerabilities and simplify compliance.

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

Docker Hardened Images (DHIs) for .NET are available in the Docker Hardened Images catalog. Docker Hardened Images are freely available to everyone with no subscription required. You can pull and use them like any other Docker image after signing in to the DHI registry. For more information, see the DHI quickstart guide.

  1. Sign in to the DHI registry:

    $ docker login dhi.io
    
  2. Pull the .NET SDK DHI (check the catalog for available versions):

    $ docker pull dhi.io/dotnet:10-sdk
    
  3. Pull the ASP.NET Core runtime DHI (check the catalog for available versions):

    $ docker pull dhi.io/aspnetcore:10
    

Create the following files in your docker-dotnet-sample directory.

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM dhi.io/dotnet:10-sdk AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app

FROM dhi.io/aspnetcore:10 AS final
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "myWebApp.dll"]

[!NOTE]

DHI runtime images already run as a non-root user (nonroot, UID 65532), so there's no need to create a user or specify USER in your Dockerfile. This reduces the attack surface and simplifies your configuration.

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  server:
    build:
      context: .
      target: final
    ports:
      - 8080:8080

# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
#     depends_on:
#       db:
#         condition: service_healthy
#   db:
#     image: postgres
#     restart: always
#     user: postgres
#     secrets:
#       - db-password
#     volumes:
#       - db-data:/var/lib/postgresql/data
#     environment:
#       - POSTGRES_DB=example
#       - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
#     expose:
#       - 5432
#     healthcheck:
#       test: [ "CMD", "pg_isready" ]
#       interval: 10s
#       timeout: 5s
#       retries: 5
# volumes:
#   db-data:
# secrets:
#   db-password:
#     file: db/password.txt
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

{{< /tab >}} {{< tab name="Using the official .NET 10 image" >}}

Create the following files in your docker-dotnet-sample directory.

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY --from=build /app .
ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser
USER appuser
ENTRYPOINT ["dotnet", "myWebApp.dll"]
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  server:
    build:
      context: .
      target: final
    ports:
      - 8080:8080

# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
#     depends_on:
#       db:
#         condition: service_healthy
#   db:
#     image: postgres
#     restart: always
#     user: postgres
#     secrets:
#       - db-password
#     volumes:
#       - db-data:/var/lib/postgresql/data
#     environment:
#       - POSTGRES_DB=example
#       - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
#     expose:
#       - 5432
#     healthcheck:
#       test: [ "CMD", "pg_isready" ]
#       interval: 10s
#       timeout: 5s
#       retries: 5
# volumes:
#   db-data:
# secrets:
#   db-password:
#     file: db/password.txt
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

{{< /tab >}} {{< /tabs >}}

You should now have the following contents in your docker-dotnet-sample directory.

├── docker-dotnet-sample/
│ ├── .git/
│ ├── src/
│ ├── .dockerignore
│ ├── compose.yaml
│ ├── Dockerfile
│ └── README.md

To learn more about the files, see the following:

Run the application

Inside the docker-dotnet-sample directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:8080. You should see a simple web application.

In the terminal, press ctrl+c to stop the application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the docker-dotnet-sample directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:8080. You should see a simple web application.

In the terminal, run the following command to stop the application.

$ docker compose down

For more information about Compose commands, see the Compose CLI reference.

Use containers for .NET development

Prerequisites

Complete Containerize a .NET application.

Overview

In this section, you'll learn how to set up a development environment for your containerized application. This includes:

Update the application

This section uses a different branch of the docker-dotnet-sample repository that contains an updated .NET application. The updated application is on the add-db branch of the repository you cloned in Containerize a .NET application.

To get the updated code, you need to checkout the add-db branch. For the changes you made in Containerize a .NET application, for this section, you can stash them. In a terminal, run the following commands in the docker-dotnet-sample directory.

  1. Stash any previous changes.

    $ git stash -u
    
  2. Check out the new branch with the updated application.

    $ git checkout add-db
    

In the add-db branch, only the .NET application has been updated. None of the Docker assets have been updated yet.

You should now have the following in your docker-dotnet-sample directory.

├── docker-dotnet-sample/
│ ├── .git/
│ ├── src/
│ │ ├── Data/
│ │ ├── Models/
│ │ ├── Pages/
│ │ ├── Properties/
│ │ ├── wwwroot/
│ │ ├── appsettings.Development.json
│ │ ├── appsettings.json
│ │ ├── myWebApp.csproj
│ │ └── Program.cs
│ ├── tests/
│ │ ├── tests.csproj
│ │ ├── UnitTest1.cs
│ │ └── Usings.cs
│ ├── .dockerignore
│ ├── .gitignore
│ ├── compose.yaml
│ ├── Dockerfile
│ └── README.md

Add a local database and persist data

You can use containers to set up local services, like a database. In this section, you'll update the compose.yaml file to define a database service and a volume to persist data.

Open the compose.yaml file in an IDE or text editor. You'll notice it already contains commented-out instructions for a PostgreSQL database and volume.

Open docker-dotnet-sample/src/appsettings.json in an IDE or text editor. You'll notice the connection string with all the database information. The compose.yaml already contains this information, but it's commented out. Uncomment the database instructions in the compose.yaml file.

The following is the updated compose.yaml file.

services:
  server:
    build:
      context: .
      target: final
    ports:
      - 8080:8080
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

[!NOTE]

To learn more about the instructions in the Compose file, see Compose file reference.

Before you run the application using Compose, notice that this Compose file uses secrets and specifies a password.txt file to hold the database's password. You must create this file as it's not included in the source repository.

In the docker-dotnet-sample directory, create a new directory named db and inside that directory create a file named password.txt. Open password.txt in an IDE or text editor and add the following password. The password must be on a single line, with no additional lines in the file.

example

Save and close the password.txt file.

You should now have the following in your docker-dotnet-sample directory.

├── docker-dotnet-sample/
│ ├── .git/
│ ├── db/
│ │ └── password.txt
│ ├── src/
│ ├── tests/
│ ├── .dockerignore
│ ├── .gitignore
│ ├── compose.yaml
│ ├── Dockerfile
│ └── README.md

Run the following command to start your application.

$ docker compose up --build

Open a browser and view the application at http://localhost:8080. You should see a simple web application with the text Student name is.

The application doesn't display a name because the database is empty. For this application, you need to access the database and then add records.

Add records to the database

For the sample application, you must access the database directly to create sample records.

You can run commands inside the database container using the docker exec command. Before running that command, you must get the ID of the database container. Open a new terminal window and run the following command to list all your running containers.

$ docker container ls

You should see output like the following.

CONTAINER ID   IMAGE                         COMMAND                  CREATED              STATUS                        PORTS                    NAMES
cb36e310aa7e   docker-dotnet-sample-server   "dotnet myWebApp.dll"    About a minute ago   Up About a minute             0.0.0.0:8080->8080/tcp   docker-dotnet-sample-server-1
39fdcf0aff7b   postgres:18                   "docker-entrypoint.s…"   About a minute ago   Up About a minute (healthy)   5432/tcp                 docker-dotnet-sample-db-1

In the previous example, the container ID is 39fdcf0aff7b. Run the following command to connect to the postgres database in the container. Replace the container ID with your own container ID.

$ docker exec -it 39fdcf0aff7b psql -d example -U postgres

And finally, insert a record into the database.

example=# INSERT INTO "Students" ("ID", "LastName", "FirstMidName", "EnrollmentDate") VALUES (DEFAULT, 'Whale', 'Moby', '2013-03-20');

You should see output like the following.

INSERT 0 1

Close the database connection and exit the container shell by running exit.

example=# exit

Verify that data persists in the database

Open a browser and view the application at http://localhost:8080. You should see a simple web application with the text Student name is Moby Whale.

Press ctrl+c in the terminal to stop your application.

In the terminal, run docker compose rm to remove your containers and then run docker compose up to run your application again.

$ docker compose rm
$ docker compose up --build

Refresh http://localhost:8080 in your browser and verify that the student name persisted, even after the containers were removed and ran again.

Press ctrl+c in the terminal to stop your application.

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Open your compose.yaml file in an IDE or text editor and then add the Compose Watch instructions. The following is the updated compose.yaml file.

services:
  server:
    build:
      context: .
      target: final
    ports:
      - 8080:8080
    depends_on:
      db:
        condition: service_healthy
    develop:
      watch:
        - action: rebuild
          path: .
  db:
    image: postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

Run the following command to run your application with Compose Watch.

$ docker compose watch

Open a browser and verify that the application is running at http://localhost:8080.

Any changes to the application's source files on your local machine will now be immediately reflected in the running container.

Open docker-dotnet-sample/src/Pages/Index.cshtml in an IDE or text editor and update the student name text on line 13 from Student name is to Student name:.

-    <p>Student name is @Model.StudentName</p>
+    <p>Student name: @Model.StudentName</p>

Save the changes to Index.cshtml and then wait a few seconds for the application to rebuild. Refresh http://localhost:8080 in your browser and verify that the updated text appears.

Press ctrl+c in the terminal to stop your application.

Create a development container

At this point, when you run your containerized application, it's using the .NET runtime image. While this small image is good for production, it lacks the SDK tools and dependencies you may need when developing. Also, during development, you may not need to run dotnet publish. You can use multi-stage builds to build stages for both development and production in the same Dockerfile. For more details, see Multi-stage builds.

Add a new development stage to your Dockerfile and update your compose.yaml file to use this stage for local development.

The following is the updated Dockerfile.

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM dhi.io/dotnet:10-sdk AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app

FROM dhi.io/dotnet:10-sdk AS development
COPY . /source
WORKDIR /source/src
CMD dotnet run --no-launch-profile

FROM dhi.io/aspnetcore:10 AS final
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "myWebApp.dll"]

{{< /tab >}} {{< tab name="Using the official .NET 10 image" >}}

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app

FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS development
COPY . /source
WORKDIR /source/src
CMD dotnet run --no-launch-profile

FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY --from=build /app .
ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser
USER appuser
ENTRYPOINT ["dotnet", "myWebApp.dll"]

{{< /tab >}} {{< /tabs >}}

The following is the updated compose.yaml file.

services:
  server:
    build:
      context: .
      target: development
    ports:
      - 8080:8080
    depends_on:
      db:
        condition: service_healthy
    develop:
      watch:
        - action: rebuild
          path: .
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
  db:
    image: postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

Your containerized application will now use the SDK image (either dhi.io/dotnet:10-sdk for DHI or mcr.microsoft.com/dotnet/sdk:10.0-alpine for official images), which includes development tools like dotnet test. Continue to the next section to learn how you can run dotnet test.

Run .NET tests in a container

Prerequisites

Complete all the previous sections of this guide, starting with Containerize a .NET application.

Overview

Testing is an essential part of modern software development. Testing can mean a lot of things to different development teams. There are unit tests, integration tests and end-to-end testing. In this guide you take a look at running your unit tests in Docker when developing and when building.

Run tests when developing locally

The sample application already has an xUnit test inside the tests directory. When developing locally, you can use Compose to run your tests.

Run the following command in the docker-dotnet-sample directory to run the tests inside a container.

$ docker compose run --build --rm server dotnet test /source/tests

You should see output that contains the following.

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: < 1 ms - /source/tests/bin/Debug/net10.0/tests.dll (net10.0)

To learn more about the command, see docker compose run.

Run tests when building

To run your tests when building, you need to update your Dockerfile. You can create a new test stage that runs the tests, or run the tests in the existing build stage. For this guide, update the Dockerfile to run the tests in the build stage.

The following is the updated Dockerfile.

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM dhi.io/dotnet:10-sdk AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app
RUN dotnet test /source/tests

FROM dhi.io/dotnet:10-sdk AS development
COPY . /source
WORKDIR /source/src
CMD dotnet run --no-launch-profile

FROM dhi.io/aspnetcore:10 AS final
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "myWebApp.dll"]

{{< /tab >}} {{< tab name="Using the official .NET 10 image" >}}

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app
RUN dotnet test /source/tests

FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS development
COPY . /source
WORKDIR /source/src
CMD dotnet run --no-launch-profile

FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY --from=build /app .
ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser
USER appuser
ENTRYPOINT ["dotnet", "myWebApp.dll"]

{{< /tab >}} {{< /tabs >}}

Run the following command to build an image using the build stage as the target and view the test results. Include --progress=plain to view the build output, --no-cache to ensure the tests always run, and --target build to target the build stage.

$ docker build -t dotnet-docker-image-test --progress=plain --no-cache --target build .

You should see output containing the following.

#11 [build 5/5] RUN dotnet test /source/tests
#11 1.564   Determining projects to restore...
#11 3.421   Restored /source/src/myWebApp.csproj (in 1.02 sec).
#11 19.42   Restored /source/tests/tests.csproj (in 17.05 sec).
#11 27.91   myWebApp -> /source/src/bin/Debug/net10.0/myWebApp.dll
#11 28.47   tests -> /source/tests/bin/Debug/net10.0/tests.dll
#11 28.49 Test run for /source/tests/bin/Debug/net10.0/tests.dll (.NETCoreApp,Version=v10.0)
#11 28.67 Microsoft (R) Test Execution Command Line Tool Version 17.3.3 (x64)
#11 28.67 Copyright (c) Microsoft Corporation.  All rights reserved.
#11 28.68
#11 28.97 Starting test execution, please wait...
#11 29.03 A total of 1 test files matched the specified pattern.
#11 32.07
#11 32.08 Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: < 1 ms - /source/tests/bin/Debug/net10.0/tests.dll (net10.0)
#11 DONE 32.2s

Summary

In this section, you learned how to run tests when developing locally using Compose and how to run tests when building your image.

Give sandboxed agents live web access with Firecrawl

Agents in a Docker Sandbox run under a network policy that usually blocks most of the web. That keeps the agent contained, but it also means the agent can't look up documentation or read a page you point it at. Firecrawl is a web data API that searches the web, scrapes pages to clean Markdown, and crawls whole sites. The Firecrawl kit gives a sandboxed agent that capability through a single API host, without opening the rest of the web.

In this guide, you'll learn how to:

What the kit adds

The Firecrawl kit is a v3 mixin published as docker.io/firecrawl/sbx-kit-firecrawl. When you add it to a workload, it contributes four things:

Capability What it does
com.docker.sandbox/lifecycle@1 An install hook that installs a pinned firecrawl-py release as the agent user. Sandbox creation fails if the install fails.
com.docker.sandbox/credential@1 Requests the key stored under the firecrawl service. The proxy adds it as Authorization: Bearer <key> on requests to api.firecrawl.dev.
com.docker.sandbox/network-policy@1 Allows pypi.org and files.pythonhosted.org during install, and api.firecrawl.dev at runtime.
com.docker.sandbox/agent-context@1 Adds instructions that tell the agent the SDK is available, how to call it, and which sandbox limits apply.

Inside the sandbox, FIRECRAWL_API_KEY holds the placeholder value proxy-managed. The SDK sends that placeholder, and the proxy on your host replaces it with your real key on the way out. Requests to any other host never carry the key.

The install hook runs against the workload's own Python, so the kit works with any workload that ships python3 and pip. Docker's sandbox workloads include both.

Prerequisites

Before you start, make sure you have:

Step 1: Store the Firecrawl key on the host

The kit declares a credential for the firecrawl service. Store your key under that name:

$ sbx secret set firecrawl

sbx prompts for the value. Service secrets apply to every sandbox by default. Use --sandbox to scope the secret to one sandbox instead.

Storing the secret doesn't give the kit access to it. The first time you run the kit, sbx asks you to approve sending the firecrawl credential to api.firecrawl.dev. For how approvals work, and how to approve in advance for unattended runs, see Credential bindings.

Step 2: Run a workload with the Firecrawl mixin

This example uses a shell workload without a coding agent so you can try the SDK directly. You can also add the Firecrawl mixin to another compatible v3 workload, including one that runs a coding agent.

From your project directory, run:

$ sbx run docker.io/docker/sbx-kit-shell:1.0.0 --name firecrawl-shell \
    --kit docker.io/firecrawl/sbx-kit-firecrawl:1.0.0

Approve the credential request when prompted. sbx pulls both kits, runs the install hook, and opens a shell in the sandbox.

For more on combining kits, see Use kits.

[!NOTE] Built-in shortcuts such as sbx run claude use v2 kits, and v2 agents only accept v2 mixins. Firecrawl also publishes a v2 form of the kit for those agents. See the kit repository for v2 usage.

Step 3: Check the kit

Run these checks from the shell in the firecrawl-shell sandbox. Each one covers a separate part of the kit.

Check that the SDK is installed at the pinned version, in the agent user's site-packages:

$ python3 -c "import firecrawl, importlib.metadata as m; print(m.version('firecrawl-py'), firecrawl.__file__)"
4.44.0 /home/agent/.local/lib/python3.14/site-packages/firecrawl/__init__.py

Check that the key in the sandbox is the proxy placeholder, not your real key:

$ echo "$FIRECRAWL_API_KEY"
proxy-managed

Then scrape a page to test the whole path: the SDK, the network rule, and credential injection. This request uses Firecrawl credits:

$ python3 - <<'EOF'
from firecrawl import Firecrawl

fc = Firecrawl()
doc = fc.scrape("https://docs.docker.com/ai/sandboxes/", formats=["markdown"])
print(doc.markdown[:300])
EOF

The output is the start of the page as Markdown. If the request fails with Unauthorized, the proxy didn't add your key. Check that you stored the secret under firecrawl and approved the kit's request. A PaymentRequiredError means the request authenticated and the account is out of credits.

Step 4: Use Firecrawl from the agent

The kit's agent instructions point the agent at the SDK, so a coding agent can use Firecrawl when a task needs current information or the contents of a URL. The main calls are:

from firecrawl import Firecrawl

fc = Firecrawl()  # The proxy adds the API key

fc.scrape("https://example.com", formats=["markdown"])  # One page as Markdown
fc.search("docker sandboxes kits", limit=5)             # Web search results
fc.crawl("https://docs.example.com", limit=20)          # Pages across a site

Each crawled page uses credits, so keep limit low and prefer a search followed by a targeted scrape when the agent needs a single fact. For the full API, including structured extraction with a schema, see the Firecrawl Python SDK documentation.

How the kit interacts with network policy

Without organization governance, your default network policy and kit-defined rules determine which domains the sandbox can access. Under organization governance, organization policy controls which domains can be allowed; local and kit-defined allow rules are inactive, while deny rules can still restrict access. See Network policy precedence.

The Firecrawl kit requests access to PyPI during installation and api.firecrawl.dev at runtime. Its credential declaration tells the proxy to inject your Firecrawl key only into requests to api.firecrawl.dev.

Firecrawl fetches target pages on its servers. Scraping a URL through Firecrawl doesn't require the sandbox to have direct access to that URL.

To see which hosts the sandbox contacted, run this on the host:

$ sbx policy log firecrawl-shell
ALLOWED REQUESTS
SANDBOX          TYPE     HOST                    PROXY    RULE            REASON   LAST SEEN        COUNT
firecrawl-shell  network  api.firecrawl.dev       forward  domain-allowed           10:16:02 15-Oct  1
firecrawl-shell  network  files.pythonhosted.org  forward  domain-allowed           10:15:41 15-Oct  6
firecrawl-shell  network  pypi.org                forward  domain-allowed           10:15:40 15-Oct  4

The scrape of docs.docker.com doesn't appear in this log because Firecrawl fetches that page on its servers. The sandbox sends its request to api.firecrawl.dev.

Clean up

Sandboxes persist after you exit. To remove the sandbox from this guide:

$ sbx rm firecrawl-shell

Files in your workspace are unaffected. To remove the stored key, run sbx secret rm firecrawl.

Learn more

Generate Docker Compose Files with Claude Code and Docker MCP Toolkit

This guide introduces how to use Claude Code together with Docker MCP Toolkit so Claude can search Docker Hub in real time and generate a complete docker-compose.yaml from natural language.

Instead of manually writing YAML or looking for image tags, you describe your stack once — Claude uses the Model Context Protocol (MCP) to query Docker Hub and build a production-ready Compose file.

In this guide, you’ll learn how to:


Use Claude Code and Docker MCP Toolkit to generate a Docker Compose file from natural language

Estimated time: ~15 minutes


1. What you are building

The goal is simple: use Claude Code together with the Docker MCP Toolkit to search Docker Hub images and generate a complete Docker Compose file for a Node.js and PostgreSQL setup.

The Model Context Protocol (MCP) bridges Claude Code and Docker Desktop, giving Claude real-time access to Docker's tools. Instead of context-switching between Docker, terminal commands, and YAML editors, you describe your requirements once and Claude handles the infrastructure details.

Why this matters: This pattern scales to complex multi-service setups, database migrations, networking, security policies — all through conversational prompts.


2. Prerequisites

Make sure you have:


3. Install the Docker Hub MCP server

  1. Open Docker Desktop
  2. Select MCP Toolkit
  3. Go to the Catalog tab
  4. Search for Docker Hub
  5. Select the Docker HubMCP server
  6. Add the MCP server, then open the Configuration tab
  7. Enter your Docker Hub username
  8. Create a read-only personal access token and enter your access token under Secrets
  9. Save the configuration

Docker Hub

Public images work without credentials. For private repositories, you can add your Docker Hub username and token later.

Docker Hub Secrets


4. Connect Claude Code to Docker MCP Toolkit

You can connect from Docker Desktop or using the CLI.

Option A. Connect with Docker Desktop

  1. Open MCP Toolkit
  2. Go to the Clients tab
  3. Locate Claude Code
  4. Select Connect

Docker Connection

Option B. Connect using the CLI

$ claude mcp add MCP_DOCKER -s user -- docker mcp gateway run

5. Verify MCP servers inside Claude Code

  1. Navigate to your project folder:
$ cd /path/to/project
  1. Start Claude Code:
$ claude
  1. In the input box, type:
/mcp

You should now see:

mcp-docker

If not, restart Claude Code or check Docker Desktop to confirm the connection.


6. Create a basic Node.js app

Claude Code generates more accurate Compose files when it can inspect a real project. Set up the application code now so the agent can bind mount it later.

Inside project folder, create a folder named app:

$ mkdir app
$ cd app
$ npm init -y
$ npm install express

Create index.js:

const express = require("express");
const app = express();

app.get("/", (req, res) => {
  res.send("Node.js, Docker, and MCP Toolkit are working together!");
});

app.listen(3000, () => {
  console.log("Server running on port 3000");
});

Add a start script to package.json:

"scripts": {
  "start": "node index.js"
}

Return to your project root (cd ..) once the app is ready.


7. Ask Claude Code to design your Docker Compose stack

Paste this message into Claude Code:

Using the Docker Hub MCP server:

Search Docker Hub for an official Node.js image and a PostgreSQL image.
Choose stable, commonly used tags such as the Node LTS version and a recent major Postgres version.

Generate a Docker Compose file (`docker-compose.yaml`) with:
- app:
  - runs on port 3000
  - bind mounts the existing ./app directory into /usr/src/app
  - sets /usr/src/app as the working directory and runs `npm install && npm start`
- db: running on port 5432 using a named volume

Include:
- Environment variables for Postgres
- A shared bridge network
- Healthchecks where appropriate
- Pin the image version using the tag + index digest

Claude will search images through MCP, inspect the app directory, and generate a Compose file that mounts and runs your local code.


8. Save the generated Docker Compose file

Tell Claude:

Save the final Docker Compose file (docker-compose.yaml) into the current project directory.

You should see something like this:

services:
  app:
    image: node:<tag>
    working_dir: /usr/src/app
    volumes:
      - .:/usr/src/app
    ports:
      - "3000:3000"
    depends_on:
      - db
    networks:
      - app-net

  db:
    image: postgres:18
    environment:
      POSTGRES_USER: example
      POSTGRES_PASSWORD: example
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql
    ports:
      - "5432:5432"
    networks:
      - app-net

volumes:
  db-data:

networks:
  app-net:
    driver: bridge

9. Run the Docker Compose stack

From your project root:

$ docker compose up

Docker will:

Open your browser:

http://localhost:3000

Local Host

Your Node.js app should now be running.


Conclusion

By combining Claude Code with the Docker MCP Toolkit, Docker Desktop, and the Docker Hub MCP server, you can describe your stack in natural language and let MCP handle the details. This removes context switching and replaces it with a smooth, guided workflow powered by model context protocol integrations.


Next steps

The future of development is not about switching between tools. It is about tools working together in a simple, safe, and predictable way. The Docker MCP Toolkit brings that future into your everyday workflow.

Learn more

Monitor a Golang application with Prometheus and Grafana

The guide teaches you how to containerize a Golang application and monitor it with Prometheus and Grafana.

Acknowledgment

Docker would like to thank Pradumna Saraf for his contribution to this guide.

Overview

To make sure your application is working as intended, monitoring is important. One of the most popular monitoring tools is Prometheus. Prometheus is an open-source monitoring and alerting toolkit that is designed for reliability and scalability. It collects metrics from monitored targets by scraping metrics HTTP endpoints on these targets. To visualize the metrics, you can use Grafana. Grafana is an open-source platform for monitoring and observability that allows you to query, visualize, alert on, and understand your metrics no matter where they are stored.

In this guide, you will be creating a Golang server with some endpoints to simulate a real-world application. Then you will expose metrics from the server using Prometheus. Finally, you will visualize the metrics using Grafana. You will containerize the Golang application, and using the Docker Compose file, you will connect all the services: Golang, Prometheus, and Grafana.

What will you learn?

Prerequisites

Next steps

You will create a Golang server and expose metrics using Prometheus.

Building the application

Prerequisites

You will be creating a Golang server with some endpoints to simulate a real-world application. Then you will expose metrics from the server using Prometheus.

Getting the sample application

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository:

$ git clone https://github.com/dockersamples/go-prometheus-monitoring.git 

Once you cloned you will see the following content structure inside go-prometheus-monitoring directory,

go-prometheus-monitoring
├── CONTRIBUTING.md
├── Docker
│   ├── grafana.yml
│   └── prometheus.yml
├── dashboard.json
├── Dockerfile
├── LICENSE
├── README.md
├── compose.yaml
├── go.mod
├── go.sum
└── main.go

Understanding the application

The following is the complete logic of the application you will find in main.go.

package main

import (
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

// Define metrics
var (
	HttpRequestTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "api_http_request_total",
		Help: "Total number of requests processed by the API",
	}, []string{"path", "status"})

	HttpRequestErrorTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "api_http_request_error_total",
		Help: "Total number of errors returned by the API",
	}, []string{"path", "status"})
)

// Custom registry (without default Go metrics)
var customRegistry = prometheus.NewRegistry()

// Register metrics with custom registry
func init() {
	customRegistry.MustRegister(HttpRequestTotal, HttpRequestErrorTotal)
}

func main() {
	router := gin.Default()

	// Register /metrics before middleware
	router.GET("/metrics", PrometheusHandler())
	
	router.Use(RequestMetricsMiddleware())
	router.GET("/health", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Up and running!",
		})
	})
	router.GET("/v1/users", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Hello from /v1/users",
		})
	})

	router.Run(":8000")
}

// Custom metrics handler with custom registry
func PrometheusHandler() gin.HandlerFunc {
	h := promhttp.HandlerFor(customRegistry, promhttp.HandlerOpts{})
	return func(c *gin.Context) {
		h.ServeHTTP(c.Writer, c.Request)
	}
}

// Middleware to record incoming requests metrics
func RequestMetricsMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		path := c.Request.URL.Path
		c.Next()
		status := c.Writer.Status()
		if status < 400 {
			HttpRequestTotal.WithLabelValues(path, strconv.Itoa(status)).Inc()
		} else {
			HttpRequestErrorTotal.WithLabelValues(path, strconv.Itoa(status)).Inc()
		}
	}
}

In this part of the code, you have imported the required packages gin, prometheus, and promhttp. Then you have defined a couple of variables, HttpRequestTotal and HttpRequestErrorTotal are Prometheus counter metrics, and customRegistry is a custom registry that will be used to register these metrics. The name of the metric is a string that you can use to identify the metric. The help string is a string that will be shown when you query the /metrics endpoint to understand the metric. The reason you are using the custom registry is so avoid the default Go metrics that are registered by default by the Prometheus client. Then using the init function you are registering the metrics with the custom registry.

import (
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

// Define metrics
var (
	HttpRequestTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "api_http_request_total",
		Help: "Total number of requests processed by the API",
	}, []string{"path", "status"})

	HttpRequestErrorTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "api_http_request_error_total",
		Help: "Total number of errors returned by the API",
	}, []string{"path", "status"})
)

// Custom registry (without default Go metrics)
var customRegistry = prometheus.NewRegistry()

// Register metrics with custom registry
func init() {
	customRegistry.MustRegister(HttpRequestTotal, HttpRequestErrorTotal)
}

In the main function, you have created a new instance of the gin framework and created three routes. You can see the health endpoint that is on path /health that will return a JSON with {"message": "Up and running!"} and the /v1/users endpoint that will return a JSON with {"message": "Hello from /v1/users"}. The third route is for the /metrics endpoint that will return the metrics in the Prometheus format. Then you have RequestMetricsMiddleware middleware, it will be called for every request made to the API. It will record the incoming requests metrics like status codes and paths. Finally, you are running the gin application on port 8000.

func main() {
	router := gin.Default()

	// Register /metrics before middleware
	router.GET("/metrics", PrometheusHandler())
	
	router.Use(RequestMetricsMiddleware())
	router.GET("/health", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Up and running!",
		})
	})
	router.GET("/v1/users", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Hello from /v1/users",
		})
	})

	router.Run(":8000")
}

Now comes the middleware function RequestMetricsMiddleware. This function is called for every request made to the API. It increments the HttpRequestTotal counter (different counter for different paths and status codes) if the status code is less than or equal to 400. If the status code is greater than 400, it increments the HttpRequestErrorTotal counter (different counter for different paths and status codes). The PrometheusHandler function is the custom handler that will be called for the /metrics endpoint. It will return the metrics in the Prometheus format.

// Custom metrics handler with custom registry
func PrometheusHandler() gin.HandlerFunc {
	h := promhttp.HandlerFor(customRegistry, promhttp.HandlerOpts{})
	return func(c *gin.Context) {
		h.ServeHTTP(c.Writer, c.Request)
	}
}

// Middleware to record incoming requests metrics
func RequestMetricsMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		path := c.Request.URL.Path
		c.Next()
		status := c.Writer.Status()
		if status < 400 {
			HttpRequestTotal.WithLabelValues(path, strconv.Itoa(status)).Inc()
		} else {
			HttpRequestErrorTotal.WithLabelValues(path, strconv.Itoa(status)).Inc()
		}
	}
}

That's it, this was the complete gist of the application. Now it's time to run and test if the app is registering metrics correctly.

Running the application

Make sure you are still inside go-prometheus-monitoring directory in the terminal, and run the following command. Install the dependencies by running go mod tidy and then build and run the application by running go run main.go. Then visit http://localhost:8000/health or http://localhost:8000/v1/users. You should see the output {"message": "Up and running!"} or {"message": "Hello from /v1/users"}. If you are able to see this then your app is successfully up and running.

Now, check your application's metrics by accessing the /metrics endpoint. Open http://localhost:8000/metrics in your browser. You should see similar output to the following.

# HELP api_http_request_error_total Total number of errors returned by the API
# TYPE api_http_request_error_total counter
api_http_request_error_total{path="/",status="404"} 1
api_http_request_error_total{path="//v1/users",status="404"} 1
api_http_request_error_total{path="/favicon.ico",status="404"} 1
# HELP api_http_request_total Total number of requests processed by the API
# TYPE api_http_request_total counter
api_http_request_total{path="/health",status="200"} 2
api_http_request_total{path="/v1/users",status="200"} 1

In the terminal, press ctrl + c to stop the application.

[!Note] If you don't want to run the application locally, and want to run it in a Docker container, skip to next page where you create a Dockerfile and containerize the application.

Summary

In this section, you learned how to create a Golang app to register metrics with Prometheus. By implementing middleware functions, you were able to increment the counters based on the request path and status codes.

Next steps

In the next section, you'll learn how to containerize your application.

Containerize a Golang application

Containerization helps you bundle the application and its dependencies into a single package called a container. This package can run on any platform without worrying about the environment. In this section, you will learn how to containerize a Golang application using Docker.

To containerize a Golang application, you first need to create a Dockerfile. The Dockerfile contains instructions to build and run the application in a container. Also, when creating a Dockerfile, you can follow different sets of best practices to optimize the image size and make it more secure.

Creating a Dockerfile

Create a new file named Dockerfile in the root directory of your Golang application. The Dockerfile contains instructions to build and run the application in a container.

The following is a Dockerfile for a Golang application. You will also find this file in the go-prometheus-monitoring directory.

# Use the official Golang image as the base
FROM golang:1.24-alpine AS builder

# Set environment variables
ENV CGO_ENABLED=0 \
    GOOS=linux \
    GOARCH=amd64

# Set working directory inside the container
WORKDIR /build

# Copy go.mod and go.sum files for dependency installation
COPY go.mod go.sum ./

# Download dependencies
RUN go mod download

# Copy the entire application source
COPY . .

# Build the Go binary
RUN go build -o /app .

# Final lightweight stage
FROM alpine:3.21 AS final

# Copy the compiled binary from the builder stage
COPY --from=builder /app /bin/app

# Expose the application's port
EXPOSE 8000

# Run the application
CMD ["bin/app"]

Understanding the Dockerfile

The Dockerfile consists of two stages:

  1. Build stage: This stage uses the official Golang image as the base and sets the necessary environment variables. It also sets the working directory inside the container, copies the go.mod and go.sum files for dependency installation, downloads the dependencies, copies the entire application source, and builds the Go binary.

    You use the golang:1.24-alpine image as the base image for the build stage. The CGO_ENABLED=0 environment variable disables CGO, which is useful for building static binaries. You also set the GOOS and GOARCH environment variables to linux and amd64, respectively, to build the binary for the Linux platform.

  2. Final stage: This stage uses the official Alpine image as the base and copies the compiled binary from the build stage. It also exposes the application's port and runs the application.

    You use the alpine:3.21 image as the base image for the final stage. You copy the compiled binary from the build stage to the final image. You expose the application's port using the EXPOSE instruction and run the application using the CMD instruction.

    Apart from the multi-stage build, the Dockerfile also follows best practices such as using the official images, setting the working directory, and copying only the necessary files to the final image. You can further optimize the Dockerfile by other best practices.

Build the Docker image and run the application

One you have the Dockerfile, you can build the Docker image and run the application in a container.

To build the Docker image, run the following command in the terminal:

$ docker build -t go-api:latest .

After building the image, you can run the application in a container using the following command:

$ docker run -p 8000:8000 go-api:latest

The application will start running inside the container, and you can access it at http://localhost:8000. You can also check the running containers using the docker ps command.

$ docker ps

Summary

In this section, you learned how to containerize a Golang application using a Dockerfile. You created a multi-stage Dockerfile to build and run the application in a container. You also learned about best practices to optimize the Docker image size and make it more secure.

Next steps

In the next section, you will learn how to use Docker Compose to connect and run multiple services together to monitor a Golang application with Prometheus and Grafana.

Connecting services with Docker Compose

Now that you have containerized the Golang application, you will use Docker Compose to connect your services together. You will connect the Golang application, Prometheus, and Grafana services together to monitor the Golang application with Prometheus and Grafana.

Creating a Docker Compose file

Create a new file named compose.yml in the root directory of your Golang application. The Docker Compose file contains instructions to run multiple services and connect them together.

Here is a Docker Compose file for a project that uses Golang, Prometheus, and Grafana. You will also find this file in the go-prometheus-monitoring directory.

services:
  api:
    container_name: go-api
    build:
      context: .
      dockerfile: Dockerfile
    image: go-api:latest
    ports:
      - 8000:8000
    networks:
      - go-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 5
    develop:
      watch:
        - path: .
          action: rebuild
      
  prometheus:
    container_name: prometheus
    image: prom/prometheus:v2.55.0
    volumes:
      - ./Docker/prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - 9090:9090
    networks:
      - go-network
  
  grafana:
    container_name: grafana
    image: grafana/grafana:11.3.0
    volumes:
      - ./Docker/grafana.yml:/etc/grafana/provisioning/datasources/datasource.yaml
      - grafana-data:/var/lib/grafana
    ports:
      - 3000:3000
    networks:
      - go-network
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=password

volumes:
  grafana-data:

networks:
  go-network:
    driver: bridge

Understanding the Docker Compose file

The Docker Compose file consists of three services:

Apart from the services, the Docker Compose file also defines a volume named grafana-data to persist the Grafana data and a network named go-network to connect the services together. You have created a custom network go-network to connect the services together. The driver: bridge field specifies the network driver to use for the network.

Building and running the services

Now that you have the Docker Compose file, you can build the services and run them together using Docker Compose.

To build and run the services, run the following command in the terminal:

$ docker compose up

The docker compose up command builds the services defined in the Docker Compose file and runs them together. You will see the similar output in the terminal:

 ✔ Network go-prometheus-monitoring_go-network  Created                                                           0.0s 
 ✔ Container grafana                            Created                                                           0.3s 
 ✔ Container go-api                             Created                                                           0.2s 
 ✔ Container prometheus                         Created                                                           0.3s 
Attaching to go-api, grafana, prometheus
go-api      | [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
go-api      | 
go-api      | [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
go-api      |  - using env:     export GIN_MODE=release
go-api      |  - using code:    gin.SetMode(gin.ReleaseMode)
go-api      | 
go-api      | [GIN-debug] GET    /metrics                  --> main.PrometheusHandler.func1 (3 handlers)
go-api      | [GIN-debug] GET    /health                   --> main.main.func1 (4 handlers)
go-api      | [GIN-debug] GET    /v1/users                 --> main.main.func2 (4 handlers)
go-api      | [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
go-api      | Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
go-api      | [GIN-debug] Listening and serving HTTP on :8000
prometheus  | ts=2025-03-15T05:57:06.676Z caller=main.go:627 level=info msg="No time or size retention was set so using the default time retention" duration=15d
prometheus  | ts=2025-03-15T05:57:06.678Z caller=main.go:671 level=info msg="Starting Prometheus Server" mode=server version="(version=2.55.0, branch=HEAD, revision=91d80252c3e528728b0f88d254dd720f6be07cb8)"
grafana     | logger=settings t=2025-03-15T05:57:06.865335506Z level=info msg="Config overridden from command line" arg="default.log.mode=console"
grafana     | logger=settings t=2025-03-15T05:57:06.865337131Z level=info msg="Config overridden from Environment variable" var="GF_PATHS_DATA=/var/lib/grafana"
grafana     | logger=ngalert.state.manager t=2025-03-15T05:57:07.088956839Z level=info msg="State
.
.
grafana     | logger=plugin.angulardetectorsprovider.dynamic t=2025-03-15T05:57:07.530317298Z level=info msg="Patterns update finished" duration=440.489125ms

The services will start running, and you can access the Golang application at http://localhost:8000, Prometheus at http://localhost:9090/health, and Grafana at http://localhost:3000. You can also check the running containers using the docker ps command.

$ docker ps

Summary

In this section, you learned how to connect services together using Docker Compose. You created a Docker Compose file to run multiple services together and connect them using networks. You also learned how to build and run the services using Docker Compose.

Next, you will learn how to develop the Golang application with Docker Compose and monitor it with Prometheus and Grafana.

Next steps

In the next section, you will learn how to develop the Golang application with Docker. You will also learn how to use Docker Compose Watch to rebuild the image whenever you make changes to the code. Lastly, you will test the application and visualize the metrics in Grafana using Prometheus as the data source.

Developing your application

In the last section, you saw how using Docker Compose, you can connect your services together. In this section, you will learn how to develop the Golang application with Docker. You will also see how to use Docker Compose Watch to rebuild the image whenever you make changes to the code. Lastly, you will test the application and visualize the metrics in Grafana using Prometheus as the data source.

Developing the application

Now, if you make any changes to your Golang application locally, it needs to reflect in the container, right? To do that, one approach is use the --build flag in Docker Compose after making changes in the code. This will rebuild all the services which have the build instruction in the compose.yml file, in your case, the api service (Golang application).

docker compose up --build

But, this is not the best approach. This is not efficient. Every time you make a change in the code, you need to rebuild manually. This is not is not a good flow for development.

The better approach is to use Docker Compose Watch. In the compose.yml file, under the service api, you have added the develop section. So, it's more like a hot reloading. Whenever you make changes to code (defined in path), it will rebuild the image (or restart depending on the action). This is how you can use it:

services:
  api:
    container_name: go-api
    build:
      context: .
      dockerfile: Dockerfile
    image: go-api:latest
    ports:
      - 8000:8000
    networks:
      - go-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 5
    develop:
      watch:
        - path: .
          action: rebuild

Once you have added the develop section in the compose.yml file, you can use the following command to start the development server:

$ docker compose watch

Now, if you modify your main.go or any other file in the project, the api service will be rebuilt automatically. You will see the following output in the terminal:

Rebuilding service(s) ["api"] after changes were detected...
[+] Building 8.1s (15/15) FINISHED                                                                                                        docker:desktop-linux
 => [api internal] load build definition from Dockerfile                                                                                                  0.0s
 => => transferring dockerfile: 704B                                                                                                                      0.0s
 => [api internal] load metadata for docker.io/library/alpine:3.17                                                                                        1.1s
  .                             
 => => exporting manifest list sha256:89ebc86fd51e27c1da440dc20858ff55fe42211a1930c2d51bbdce09f430c7f1                                                    0.0s
 => => naming to docker.io/library/go-api:latest                                                                                                          0.0s
 => => unpacking to docker.io/library/go-api:latest                                                                                                       0.0s
 => [api] resolving provenance for metadata file                                                                                                          0.0s
service(s) ["api"] successfully built

Testing the application

Now that you have your application running, head over to the Grafana dashboard to visualize the metrics you are registering. Open your browser and navigate to http://localhost:3000. You will be greeted with the Grafana login page. The login credentials are the ones provided in Compose file.

Once you are logged in, you can create a new dashboard. While creating dashboard you will notice that is default data source is Prometheus. This is because you have already configured the data source in the grafana.yml file.

The optional settings screen with the options specified.

You can use different panels to visualize the metrics. This guide doesn't go into details of Grafana. You can refer to the Grafana documentation for more information. There is a Bar Gauge panel to visualize the total number of requests from different endpoints. You used the api_http_request_total and api_http_request_error_total metrics to get the data.

The optional settings screen with the options specified.

You created this panel to visualize the total number of requests from different endpoints to compare the successful and failed requests. For all the good requests, the bar will be green, and for all the failed requests, the bar will be red. Plus it will also show the from which endpoint the request is coming, either it's a successful request or a failed request. If you want to use this panel, you can import the dashboard.json file from the repository you cloned.

Summary

You've come to the end of this guide. You learned how to develop the Golang application with Docker. You also saw how to use Docker Compose Watch to rebuild the image whenever you make changes to the code. Lastly, you tested the application and visualized the metrics in Grafana using Prometheus as the data source.

Go language-specific guide

This guide will show you how to create, test, and deploy containerized Go applications using Docker.

Acknowledgment

Docker would like to thank Oliver Frolovs for his contribution to this guide.

What will you learn?

In this guide, you’ll learn how to:

Prerequisites

Some basic understanding of Go and its toolchain is assumed. This isn't a Go tutorial. If you are new to the : languages:, the Go website is a great place to explore, so go (pun intended) check it out!

You also must know some basic Docker concepts as well as to be at least vaguely familiar with the Dockerfile format.

Your Docker set-up must have BuildKit enabled. BuildKit is enabled by default for all users on Docker Desktop. If you have installed Docker Desktop, you don’t have to manually enable BuildKit. If you are running Docker on Linux, please check out BuildKit getting started page.

Some familiarity with the command line is also expected.

What's next?

The aim of this guide is to provide enough examples and instructions for you to containerize your own Go application and deploy it into the Cloud.

Start by building your first Go image.

Build your Go image

Overview

In this section you're going to build a container image. The image includes everything you need to run your application – the compiled application binary file, the runtime, the libraries, and all other resources required by your application.

Required software

To complete this tutorial, you need the following:

Meet the example application

The example application is a caricature of a microservice. It is purposefully trivial to keep focus on learning the basics of containerization for Go applications.

The application offers two HTTP endpoints:

It responds with HTTP error 404 to any other request.

The application listens on a TCP port defined by the value of environment variable PORT. The default value is 8080.

The application is stateless.

The complete source code for the application is on GitHub: github.com/docker/docker-gs-ping. You are encouraged to fork it and experiment with it as much as you like.

To continue, clone the application repository to your local machine:

$ git clone https://github.com/docker/docker-gs-ping

The application's main.go file is straightforward, if you are familiar with Go:

package main

import (
	"net/http"
	"os"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {

	e := echo.New()

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	e.GET("/", func(c echo.Context) error {
		return c.HTML(http.StatusOK, "Hello, Docker! <3")
	})

	e.GET("/health", func(c echo.Context) error {
		return c.JSON(http.StatusOK, struct{ Status string }{Status: "OK"})
	})

	httpPort := os.Getenv("PORT")
	if httpPort == "" {
		httpPort = "8080"
	}

	e.Logger.Fatal(e.Start(":" + httpPort))
}

// Simple implementation of an integer minimum
// Adapted from: https://gobyexample.com/testing-and-benchmarking
func IntMin(a, b int) int {
	if a < b {
		return a
	}
	return b
}

Create a Dockerfile for the application

To build a container image with Docker, a Dockerfile with build instructions is required.

Begin your Dockerfile with the (optional) parser directive line that instructs BuildKit to interpret your file according to the grammar rules for the specified version of the syntax.

You then tell Docker what base image you would like to use for your application:

# syntax=docker/dockerfile:1

FROM golang:1.19

Docker images can be inherited from other images. Therefore, instead of creating your own base image from scratch, you can use the official Go image that already has all necessary tools and libraries to compile and run a Go application.

[!NOTE]

If you are curious about creating your own base images, you can check out the following section of this guide: creating base images. Note, however, that this isn't necessary to continue with your task at hand.

Now that you have defined the base image for your upcoming container image, you can begin building on top of it.

To make things easier when running the rest of your commands, create a directory inside the image that you're building. This also instructs Docker to use this directory as the default destination for all subsequent commands. This way you don't have to type out full file paths in the Dockerfile, the relative paths will be based on this directory.

WORKDIR /app

Usually the very first thing you do once you’ve downloaded a project written in Go is to install the modules necessary to compile it. Note, that the base image has the toolchain already, but your source code isn't in it yet.

So before you can run go mod download inside your image, you need to get your go.mod and go.sum files copied into it. Use the COPY command to do this.

In its simplest form, the COPY command takes two parameters. The first parameter tells Docker what files you want to copy into the image. The last parameter tells Docker where you want that file to be copied to.

Copy the go.mod and go.sum file into your project directory /app which, owing to your use of WORKDIR, is the current directory (./) inside the image. Unlike some modern shells that appear to be indifferent to the use of trailing slash (/), and can figure out what the user meant (most of the time), Docker's COPY command is quite sensitive in its interpretation of the trailing slash.

COPY go.mod go.sum ./

[!NOTE]

If you'd like to familiarize yourself with the trailing slash treatment by the COPY command, see Dockerfile reference. This trailing slash can cause issues in more ways than you can imagine.

Now that you have the module files inside the Docker image that you are building, you can use the RUN command to run the command go mod download there as well. This works exactly the same as if you were running go locally on your machine, but this time these Go modules will be installed into a directory inside the image.

RUN go mod download

At this point, you have a Go toolchain version 1.19.x and all your Go dependencies installed inside the image.

The next thing you need to do is to copy your source code into the image. You’ll use the COPY command just like you did with your module files before.

COPY *.go ./

This COPY command uses a wildcard to copy all files with .go extension located in the current directory on the host (the directory where the Dockerfile is located) into the current directory inside the image.

COPY *.go ./ only matches .go files in that one directory. Packages under pkg/ or other subdirectories are left out, so go build then fails to find them. For a multi-package module, copy the module root instead (COPY . .) and use a .dockerignore file to keep the context small.

Now, to compile your application, use the familiar RUN command:

RUN CGO_ENABLED=0 GOOS=linux go build -o /docker-gs-ping

This should be familiar. The result of that command will be a static application binary named docker-gs-ping and located in the root of the filesystem of the image that you are building. You could have put the binary into any other place you desire inside that image, the root directory has no special meaning in this regard. It's convenient to use it to keep the file paths short for improved readability.

Now, all that is left to do is to tell Docker what command to run when your image is used to start a container.

You do this with the CMD command:

CMD ["/docker-gs-ping"]

Here's the complete Dockerfile:

# syntax=docker/dockerfile:1

FROM golang:1.19

# Set destination for COPY
WORKDIR /app

# Download Go modules
COPY go.mod go.sum ./
RUN go mod download

# Copy the source code. Note the slash at the end, as explained in
# https://docs.docker.com/reference/dockerfile/#copy
COPY *.go ./

# Build
RUN CGO_ENABLED=0 GOOS=linux go build -o /docker-gs-ping

# Optional:
# To bind to a TCP port, runtime parameters must be supplied to the docker command.
# But we can document in the Dockerfile what ports
# the application is going to listen on by default.
# https://docs.docker.com/reference/dockerfile/#expose
EXPOSE 8080

# Run
CMD ["/docker-gs-ping"]

The Dockerfile may also contain comments. They always begin with a # symbol, and must be at the beginning of a line. Comments are there for your convenience to allow documenting your Dockerfile.

There is also a concept of Dockerfile directives, such as the syntax directive you added. The directives must always be at the very top of the Dockerfile, so when adding comments, make sure that the comments follow after any directives that you may have used:

# syntax=docker/dockerfile:1
# A sample microservice in Go packaged into a container image.

FROM golang:1.19

# ...

Build the image

Now that you've created your Dockerfile, build an image from it. The docker build command creates Docker images from the Dockerfile and a context. A build context is the set of files located in the specified path or URL. The Docker build process can access any of the files located in the context.

The build command optionally takes a --tag flag. This flag is used to label the image with a string value, which is easy for humans to read and recognize. If you don't pass a --tag, Docker will use latest as the default value.

Build your first Docker image.

$ docker build --tag docker-gs-ping .

The build process will print some diagnostic messages as it goes through the build steps. The following is an example of what these messages may look like.

[+] Building 2.2s (15/15) FINISHED
 => [internal] load build definition from Dockerfile                                                                                       0.0s
 => => transferring dockerfile: 701B                                                                                                       0.0s
 => [internal] load .dockerignore                                                                                                          0.0s
 => => transferring context: 2B                                                                                                            0.0s
 => resolve image config for docker.io/docker/dockerfile:1                                                                                 1.1s
 => CACHED docker-image://docker.io/docker/dockerfile:1@sha256:39b85bbfa7536a5feceb7372a0817649ecb2724562a38360f4d6a7782a409b14            0.0s
 => [internal] load build definition from Dockerfile                                                                                       0.0s
 => [internal] load .dockerignore                                                                                                          0.0s
 => [internal] load metadata for docker.io/library/golang:1.19                                                                             0.7s
 => [1/6] FROM docker.io/library/golang:1.19@sha256:5d947843dde82ba1df5ac1b2ebb70b203d106f0423bf5183df3dc96f6bc5a705                       0.0s
 => [internal] load build context                                                                                                          0.0s
 => => transferring context: 6.08kB                                                                                                        0.0s
 => CACHED [2/6] WORKDIR /app                                                                                                              0.0s
 => CACHED [3/6] COPY go.mod go.sum ./                                                                                                     0.0s
 => CACHED [4/6] RUN go mod download                                                                                                       0.0s
 => CACHED [5/6] COPY *.go ./                                                                                                              0.0s
 => CACHED [6/6] RUN CGO_ENABLED=0 GOOS=linux go build -o /docker-gs-ping                                                                  0.0s
 => exporting to image                                                                                                                     0.0s
 => => exporting layers                                                                                                                    0.0s
 => => writing image sha256:ede8ff889a0d9bc33f7a8da0673763c887a258eb53837dd52445cdca7b7df7e3                                               0.0s
 => => naming to docker.io/library/docker-gs-ping                                                                                          0.0s

Your exact output will vary, but provided there aren't any errors, you should see the word FINISHED in the first line of output. This means Docker has successfully built your image named docker-gs-ping.

View local images

To see the list of images you have on your local machine, you have two options. One is to use the CLI and the other is to use Docker Desktop. Since you're working in the terminal, take a look at listing images with the CLI.

To list images, run the docker image lscommand (or the docker images shorthand):

$ docker image ls

REPOSITORY                       TAG       IMAGE ID       CREATED         SIZE
docker-gs-ping                   latest    7f153fbcc0a8   2 minutes ago   1.11GB
...

Your exact output may vary, but you should see the docker-gs-ping image with the latest tag. Because you didn't specify a custom tag when you built your image, Docker assumed that the tag would be latest, which is a special value.

Tag images

An image name is made up of slash-separated name components. Name components may contain lowercase letters, digits, and separators. A separator is defined as a period, one or two underscores, or one or more dashes. A name component may not start or end with a separator.

An image is made up of a manifest and a list of layers. In simple terms, a tag points to a combination of these artifacts. You can have multiple tags for the image and, in fact, most images have multiple tags. Create a second tag for the image you built and take a look at its layers.

Use the docker image tag (or docker tag shorthand) command to create a new tag for your image. This command takes two arguments; the first argument is the source image, and the second is the new tag to create. The following command creates a new docker-gs-ping:v1.0 tag for the docker-gs-ping:latest you built:

$ docker image tag docker-gs-ping:latest docker-gs-ping:v1.0

The Docker tag command creates a new tag for the image. It doesn't create a new image. The tag points to the same image and is another way to reference the image.

Now run the docker image ls command again to see the updated list of local images:

$ docker image ls

REPOSITORY                       TAG       IMAGE ID       CREATED         SIZE
docker-gs-ping                   latest    7f153fbcc0a8   6 minutes ago   1.11GB
docker-gs-ping                   v1.0      7f153fbcc0a8   6 minutes ago   1.11GB
...

You can see that you have two images that start with docker-gs-ping. You know they're the same image because if you look at the IMAGE ID column, you can see that the values are the same for the two images. This value is a unique identifier Docker uses internally to identify the image.

Remove the tag that you just created. To do this, you’ll use the docker image rm command, or the shorthand docker rmi (which stands for "remove image"):

$ docker image rm docker-gs-ping:v1.0
Untagged: docker-gs-ping:v1.0

Notice that the response from Docker tells you that the image hasn't been removed but only untagged.

Verify this by running the following command:

$ docker image ls

You will see that the tag v1.0 is no longer in the list of images kept by your Docker instance.

REPOSITORY                       TAG       IMAGE ID       CREATED         SIZE
docker-gs-ping                   latest    7f153fbcc0a8   7 minutes ago   1.11GB
...

The tag v1.0 has been removed but you still have the docker-gs-ping:latest tag available on your machine, so the image is there.

Multi-stage builds

You may have noticed that your docker-gs-ping image weighs in at over a gigabyte, which is a lot for a tiny compiled Go application. You may also be wondering what happened to the full suite of Go tools, including the compiler, after you had built your image.

The answer is that the full toolchain is still there, in the container image. Not only this is inconvenient because of the large file size, but it may also present a security risk when the container is deployed.

These two issues can be solved by using multi-stage builds.

In a nutshell, a multi-stage build can carry over the artifacts from one build stage into another, and every build stage can be instantiated from a different base image.

Thus, in the following example, you are going to use a full-scale official Go image to build your application. Then you'll copy the application binary into another image whose base is very lean and doesn't include the Go toolchain or other optional components.

The Dockerfile.multistage in the sample application's repository has the following content:

# syntax=docker/dockerfile:1

# Build the application from source
FROM golang:1.19 AS build-stage

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY *.go ./

RUN CGO_ENABLED=0 GOOS=linux go build -o /docker-gs-ping

# Run the tests in the container
FROM build-stage AS run-test-stage
RUN go test -v ./...

# Deploy the application binary into a lean image
FROM gcr.io/distroless/base-debian12 AS build-release-stage

WORKDIR /

COPY --from=build-stage /docker-gs-ping /docker-gs-ping

EXPOSE 8080

USER nonroot:nonroot

ENTRYPOINT ["/docker-gs-ping"]

Since you have two Dockerfiles now, you have to tell Docker what Dockerfile you'd like to use to build the image. Tag the new image with multistage. This tag (like any other, apart from latest) has no special meaning for Docker, it's something you chose.

$ docker build -t docker-gs-ping:multistage -f Dockerfile.multistage .

Comparing the sizes of docker-gs-ping:multistage and docker-gs-ping:latest you see a few orders-of-magnitude difference.

$ docker image ls
REPOSITORY       TAG          IMAGE ID       CREATED              SIZE
docker-gs-ping   multistage   e3fdde09f172   About a minute ago   28.1MB
docker-gs-ping   latest       336a3f164d0f   About an hour ago    1.11GB

This is so because the "distroless" base image that you have used in the second stage of the build is very barebones and is designed for lean deployments of static binaries.

There's much more to multi-stage builds, including the possibility of multi-architecture builds, so feel free to check out multi-stage builds. This is, however, not essential for your progress here.

Run your Go image as a container

Prerequisites

Work through the steps to containerize a Go application in Build your Go image.

Overview

In the previous module you created a Dockerfile for your example application and then you created your Docker image using the command docker build. Now that you have the image, you can run that image and see if your application is running correctly.

A container is a normal operating system process except that this process is isolated and has its own file system, its own networking, and its own isolated process tree separate from the host.

To run an image inside of a container, you use the docker run command. It requires one parameter and that's the image name. Start your image and make sure it's running correctly. Run the following command in your terminal.

$ docker run docker-gs-ping
   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.10.2
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:8080

When you run this command, you’ll notice that you weren't returned to the command prompt. This is because your application is a REST server and will run in a loop waiting for incoming requests without returning control back to the OS until you stop the container.

Make a GET request to the server using the curl command.

$ curl http://localhost:8080/
curl: (7) Failed to connect to localhost port 8080: Connection refused

Your curl command failed because the connection to your server was refused. Meaning that you weren't able to connect to localhost on port 8080. This is expected because your container is running in isolation which includes networking. Stop the container and restart with port 8080 published on your local network.

To stop the container, press ctrl-c. This will return you to the terminal prompt.

To publish a port for your container, you’ll use the --publish flag (-p for short) on the docker run command. The format of the --publish command is [host_port]:[container_port]. So if you wanted to expose port 8080 inside the container to port 3000 outside the container, you would pass 3000:8080 to the --publish flag.

Start the container and expose port 8080 to port 8080 on the host.

$ docker run --publish 8080:8080 docker-gs-ping

Now, rerun the curl command.

$ curl http://localhost:8080/
Hello, Docker! <3

Success! You were able to connect to the application running inside of your container on port 8080. Switch back to the terminal where your container is running and you should see the GET request logged to the console.

Press ctrl-c to stop the container.

Run in detached mode

This is great so far, but your sample application is a web server and you shouldn't have to have your terminal connected to the container. Docker can run your container in detached mode in the background. To do this, you can use the --detach or -d for short. Docker will start your container the same as before but this time will detach from the container and return you to the terminal prompt.

$ docker run -d -p 8080:8080 docker-gs-ping
d75e61fcad1e0c0eca69a3f767be6ba28a66625ce4dc42201a8a323e8313c14e

Docker started your container in the background and printed the container ID on the terminal.

Again, make sure that your container is running. Run the same curl command:

$ curl http://localhost:8080/
Hello, Docker! <3

List containers

Since you ran your container in the background, how do you know if your container is running or what other containers are running on your machine? Well, to see a list of containers running on your machine, run docker ps. This is similar to how the ps command is used to see a list of processes on a Linux machine.

$ docker ps

CONTAINER ID   IMAGE            COMMAND             CREATED          STATUS          PORTS                    NAMES
d75e61fcad1e   docker-gs-ping   "/docker-gs-ping"   41 seconds ago   Up 40 seconds   0.0.0.0:8080->8080/tcp   inspiring_ishizaka

The ps command tells you a bunch of stuff about your running containers. You can see the container ID, the image running inside the container, the command that was used to start the container, when it was created, the status, ports that are exposed, and the names of the container.

You are probably wondering where the name of your container is coming from. Since you didn’t provide a name for the container when you started it, Docker generated a random name. You'll fix this in a minute but first you need to stop the container. To stop the container, run the docker stop command, passing the container's name or ID.

$ docker stop inspiring_ishizaka
inspiring_ishizaka

Now rerun the docker ps command to see a list of running containers.

$ docker ps

CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

Stop, start, and name containers

Docker containers can be started, stopped and restarted. When you stop a container, it's not removed but the status is changed to stopped and the process inside of the container is stopped. When you ran the docker ps command, the default output is to only show running containers. If you pass the --all or -a for short, you will see all containers on your system, including stopped containers and running containers.

$ docker ps --all

CONTAINER ID   IMAGE            COMMAND                  CREATED              STATUS                      PORTS     NAMES
d75e61fcad1e   docker-gs-ping   "/docker-gs-ping"        About a minute ago   Exited (2) 23 seconds ago             inspiring_ishizaka
f65dbbb9a548   docker-gs-ping   "/docker-gs-ping"        3 minutes ago        Exited (2) 2 minutes ago              wizardly_joliot
aade1bf3d330   docker-gs-ping   "/docker-gs-ping"        3 minutes ago        Exited (2) 3 minutes ago              magical_carson
52d5ce3c15f0   docker-gs-ping   "/docker-gs-ping"        9 minutes ago        Exited (2) 3 minutes ago              gifted_mestorf

If you’ve been following along, you should see several containers listed. These are containers that you started and stopped but haven't removed yet.

Restart the container that you have just stopped. Locate the name of the container and replace the name of the container in the following restart command:

$ docker restart inspiring_ishizaka

Now, list all the containers again using the ps command:

$ docker ps -a

CONTAINER ID   IMAGE            COMMAND                  CREATED          STATUS                     PORTS                    NAMES
d75e61fcad1e   docker-gs-ping   "/docker-gs-ping"        2 minutes ago    Up 5 seconds               0.0.0.0:8080->8080/tcp   inspiring_ishizaka
f65dbbb9a548   docker-gs-ping   "/docker-gs-ping"        4 minutes ago    Exited (2) 2 minutes ago                            wizardly_joliot
aade1bf3d330   docker-gs-ping   "/docker-gs-ping"        4 minutes ago    Exited (2) 4 minutes ago                            magical_carson
52d5ce3c15f0   docker-gs-ping   "/docker-gs-ping"        10 minutes ago   Exited (2) 4 minutes ago                            gifted_mestorf

Notice that the container you just restarted has been started in detached mode and has port 8080 exposed. Also, note that the status of the container is Up X seconds. When you restart a container, it will be started with the same flags or commands that it was originally started with.

Stop and remove all of your containers and take a look at fixing the random naming issue.

Stop the container you just started. Find the name of your running container and replace the name in the following command with the name of the container on your system:

$ docker stop inspiring_ishizaka
inspiring_ishizaka

Now that all of your containers are stopped, remove them. When a container is removed, it's no longer running nor is it in the stopped state. Instead, the process inside the container is terminated and the metadata for the container is removed.

To remove a container, run the docker rm command passing the container name. You can pass multiple container names to the command in one command.

Again, make sure you replace the containers names in the following command with the container names from your system:

$ docker rm inspiring_ishizaka wizardly_joliot magical_carson gifted_mestorf

inspiring_ishizaka
wizardly_joliot
magical_carson
gifted_mestorf

Run the docker ps --all command again to verify that all containers are gone.

Now, address the pesky random name issue. Standard practice is to name your containers for the simple reason that it's easier to identify what's running in the container and what application or service it's associated with. Just like good naming conventions for variables in your code makes it simpler to read. So goes naming your containers.

To name a container, you must pass the --name flag to the run command:

$ docker run -d -p 8080:8080 --name rest-server docker-gs-ping
3bbc6a3102ea368c8b966e1878a5ea9b1fc61187afaac1276c41db22e4b7f48f
$ docker ps

CONTAINER ID   IMAGE            COMMAND             CREATED          STATUS          PORTS                    NAMES
3bbc6a3102ea   docker-gs-ping   "/docker-gs-ping"   25 seconds ago   Up 24 seconds   0.0.0.0:8080->8080/tcp   rest-server

Now, you can easily identify your container based on the name.

Use containers for Go development

Prerequisites

Work through the steps of the run your image as a container module to learn how to manage the lifecycle of your containers.

Introduction

In this module, you'll take a look at running a database engine in a container and connecting it to the extended version of the example application. You are going to see some options for keeping persistent data and for wiring up the containers to talk to one another. Finally, you'll learn how to use Docker Compose to manage such multi-container local development environments effectively.

Local database and containers

The database engine you are going to use is called CockroachDB. It is a modern, Cloud-native, distributed SQL database.

Instead of compiling CockroachDB from the source code or using the operating system's native package manager to install CockroachDB, you are going to use the Docker image for CockroachDB and run it in a container.

CockroachDB is compatible with PostgreSQL to a significant extent, and shares many conventions with the latter, particularly the default names for the environment variables. So, if you are familiar with Postgres, don't be surprised if you see some familiar environment variable names. The Go modules that work with Postgres, such as pgx, pq, GORM, and upper/db also work with CockroachDB.

For more information on the relation between Go and CockroachDB, refer to the CockroachDB documentation, although this isn't necessary to continue with the present guide.

Storage

The point of a database is to have a persistent store of data. Volumes are the preferred mechanism for persisting data generated by and used by Docker containers. Thus, before you start CockroachDB, create the volume for it.

To create a managed volume, run :

$ docker volume create roach
roach

You can view the list of all managed volumes in your Docker instance with the following command:

$ docker volume list
DRIVER    VOLUME NAME
local     roach

Networking

The example application and the database engine are going to talk to one another over the network. There are different kinds of network configuration possible, and you're going to use what's called a user-defined bridge network. It is going to provide you with a DNS lookup service so that you can refer to your database engine container by its hostname.

The following command creates a new bridge network named mynet:

$ docker network create -d bridge mynet
51344edd6430b5acd121822cacc99f8bc39be63dd125a3b3cd517b6485ab7709

As it was the case with the managed volumes, there is a command to list all networks set up in your Docker instance:

$ docker network list
NETWORK ID     NAME          DRIVER    SCOPE
0ac2b1819fa4   bridge        bridge    local
51344edd6430   mynet         bridge    local
daed20bbecce   host          host      local
6aee44f40a39   none          null      local

Your bridge network mynet has been created successfully. The other three networks, named bridge, host, and none are the default networks and they had been created by the Docker itself. While it's not relevant to this guide, you can learn more about Docker networking in the networking overview section.

Choose good names for volumes and networks

As the saying goes, there are only two hard things in Computer Science: cache invalidation and naming things. And off-by-one errors.

When choosing a name for a network or a managed volume, it's best to choose a name that's indicative of the intended purpose. This guide aims for brevity, so it used short, generic names.

Start the database engine

Now that the housekeeping chores are done, you can run CockroachDB in a container and attach it to the volume and network you had just created. When you run the following command, Docker will pull the image from Docker Hub and run it for you locally:

$ docker run -d \
  --name roach \
  --hostname db \
  --network mynet \
  -p 26257:26257 \
  -p 8080:8080 \
  -v roach:/cockroach/cockroach-data \
  cockroachdb/cockroach:latest-v25.4 start-single-node \
  --insecure

# ... output omitted ...

Notice a clever use of the tag latest-v25.4 to make sure that you're pulling the latest patch version of 25.4. The diversity of available tags depend on the image maintainer. Here, your intent was to have the latest patched version of CockroachDB while not straying too far away from the known working version as the time goes by. To see the tags available for the CockroachDB image, you can go to the CockroachDB page on Docker Hub.

Configure the database engine

Now that the database engine is live, there is some configuration to do before your application can begin using it. Fortunately, it's not a lot. You must:

  1. Create a blank database.
  2. Register a new user account with the database engine.
  3. Grant that new user access rights to the database.

You can do that with the help of CockroachDB built-in SQL shell. To start the SQL shell in the same container where the database engine is running, type:

$ docker exec -it roach ./cockroach sql --insecure
  1. In the SQL shell, create the database that the example application is going to use:

    CREATE DATABASE mydb;
    
  2. Register a new SQL user account with the database engine. Use the username totoro.

    CREATE USER totoro;
    
  3. Give the new user the necessary permissions:

    GRANT ALL ON DATABASE mydb TO totoro;
    
  4. Type quit to exit the shell.

The following is an example of interaction with the SQL shell.

$ sudo docker exec -it roach ./cockroach sql --insecure
#
# Welcome to the CockroachDB SQL shell.
# All statements must be terminated by a semicolon.
# To exit, type: \q.
#
# Server version: CockroachDB CCL v20.1.15 (x86_64-unknown-linux-gnu, built 2021/04/26 16:11:58, go1.13.9) (same version as client)
# Cluster ID: 7f43a490-ccd6-4c2a-9534-21f393ca80ce
#
# Enter \? for a brief introduction.
#
root@:26257/defaultdb> CREATE DATABASE mydb;
CREATE DATABASE

Time: 22.985478ms

root@:26257/defaultdb> CREATE USER totoro;
CREATE ROLE

Time: 13.921659ms

root@:26257/defaultdb> GRANT ALL ON DATABASE mydb TO totoro;
GRANT

Time: 14.217559ms

root@:26257/defaultdb> quit
oliver@hki:~$

Meet the example application

Now that you have started and configured the database engine, you can switch your attention to the application.

The example application for this module is an extended version of docker-gs-ping application you've used in the previous modules. You have two options:

To checkout the example application, run:

$ git clone https://github.com/docker/docker-gs-ping-dev.git
# ... output omitted ...

The application's main.go now includes database initialization code, as well as the code to implement a new business requirement:

You also have an update for another business requirement. The requirement was:

And now it's going to be:

The full source code listing of main.go follows.

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/cenkalti/backoff/v4"
	"github.com/cockroachdb/cockroach-go/v2/crdb"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {

	e := echo.New()

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	db, err := initStore()
	if err != nil {
		log.Fatalf("failed to initialize the store: %s", err)
	}
	defer db.Close()

	e.GET("/", func(c echo.Context) error {
		return rootHandler(db, c)
	})

	e.GET("/ping", func(c echo.Context) error {
		return c.JSON(http.StatusOK, struct{ Status string }{Status: "OK"})
	})

	e.POST("/send", func(c echo.Context) error {
		return sendHandler(db, c)
	})

	httpPort := os.Getenv("HTTP_PORT")
	if httpPort == "" {
		httpPort = "8080"
	}

	e.Logger.Fatal(e.Start(":" + httpPort))
}

type Message struct {
	Value string `json:"value"`
}

func initStore() (*sql.DB, error) {

	pgConnString := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=disable",
		os.Getenv("PGHOST"),
		os.Getenv("PGPORT"),
		os.Getenv("PGDATABASE"),
		os.Getenv("PGUSER"),
		os.Getenv("PGPASSWORD"),
	)

	var (
		db  *sql.DB
		err error
	)
	openDB := func() error {
		db, err = sql.Open("postgres", pgConnString)
		return err
	}

	err = backoff.Retry(openDB, backoff.NewExponentialBackOff())
	if err != nil {
		return nil, err
	}

	if _, err := db.Exec(
		"CREATE TABLE IF NOT EXISTS message (value TEXT PRIMARY KEY)"); err != nil {
		return nil, err
	}

	return db, nil
}

func rootHandler(db *sql.DB, c echo.Context) error {
	r, err := countRecords(db)
	if err != nil {
		return c.HTML(http.StatusInternalServerError, err.Error())
	}
	return c.HTML(http.StatusOK, fmt.Sprintf("Hello, Docker! (%d)\n", r))
}

func sendHandler(db *sql.DB, c echo.Context) error {

	m := &Message{}

	if err := c.Bind(m); err != nil {
		return c.JSON(http.StatusInternalServerError, err)
	}

	err := crdb.ExecuteTx(context.Background(), db, nil,
		func(tx *sql.Tx) error {
			_, err := tx.Exec(
				"INSERT INTO message (value) VALUES ($1) ON CONFLICT (value) DO UPDATE SET value = excluded.value",
				m.Value,
			)
			if err != nil {
				return c.JSON(http.StatusInternalServerError, err)
			}
			return nil
		})

	if err != nil {
		return c.JSON(http.StatusInternalServerError, err)
	}

	return c.JSON(http.StatusOK, m)
}

func countRecords(db *sql.DB) (int, error) {

	rows, err := db.Query("SELECT COUNT(*) FROM message")
	if err != nil {
		return 0, err
	}
	defer rows.Close()

	count := 0
	for rows.Next() {
		if err := rows.Scan(&count); err != nil {
			return 0, err
		}
		rows.Close()
	}

	return count, nil
}

The repository also includes the Dockerfile, which is almost exactly the same as the multi-stage Dockerfile introduced in the previous modules. It uses the official Docker Go image to build the application and then builds the final image by placing the compiled binary into the much slimmer, distroless image.

Regardless of whether you had updated the old example application, or checked out the new one, this new Docker image has to be built to reflect the changes to the application source code.

Build the application

You can build the image with the familiar build command:

$ docker build --tag docker-gs-ping-roach .

Run the application

Now, run your container. This time you'll need to set some environment variables so that your application knows how to access the database. For now, you’ll do this right in the docker run command. Later you'll see a more convenient method with Docker Compose.

[!NOTE]

Since you're running your CockroachDB cluster in insecure mode, the value for the password can be anything.

In production, don't run in insecure mode.

$ docker run -it --rm -d \
  --network mynet \
  --name rest-server \
  -p 80:8080 \
  -e PGUSER=totoro \
  -e PGPASSWORD=myfriend \
  -e PGHOST=db \
  -e PGPORT=26257 \
  -e PGDATABASE=mydb \
  docker-gs-ping-roach

There are a few points to note about this command.

Test the application

In the previous section, you've already tested querying your application with GET and it returned zero for the stored message counter. Now, post some messages to it:

$ curl --request POST \
  --url http://localhost/send \
  --header 'content-type: application/json' \
  --data '{"value": "Hello, Docker!"}'

The application responds with the contents of the message, which means it has been saved in the database:

{ "value": "Hello, Docker!" }

Send another message:

$ curl --request POST \
  --url http://localhost/send \
  --header 'content-type: application/json' \
  --data '{"value": "Hello, Oliver!"}'

And again, you get the value of the message back:

{ "value": "Hello, Oliver!" }

Run curl and see what the message counter says:

$ curl localhost
Hello, Docker! (2)

In this example, you sent two messages and the database kept them. Or has it? Stop and remove all your containers, but not the volumes, and try again.

First, stop the containers:

$ docker container stop rest-server roach
rest-server
roach

Then, remove them:

$ docker container rm rest-server roach
rest-server
roach

Verify that they're gone:

$ docker container list --all
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

And start them again, database first:

$ docker run -d \
  --name roach \
  --hostname db \
  --network mynet \
  -p 26257:26257 \
  -p 8080:8080 \
  -v roach:/cockroach/cockroach-data \
  cockroachdb/cockroach:latest-v25.4 start-single-node \
  --insecure

And the service next:

$ docker run -it --rm -d \
  --network mynet \
  --name rest-server \
  -p 80:8080 \
  -e PGUSER=totoro \
  -e PGPASSWORD=myfriend \
  -e PGHOST=db \
  -e PGPORT=26257 \
  -e PGDATABASE=mydb \
  docker-gs-ping-roach

Lastly, query your service:

$ curl localhost
Hello, Docker! (2)

Great! The count of records from the database is correct although you haven't only stopped the containers, but you've also removed them before starting new instances. The difference is in the managed volume for CockroachDB, which you reused. The new CockroachDB container has read the database files from the disk, just as it normally would if it were running outside the container.

Wind down everything

Remember, that you're running CockroachDB in insecure mode. Now that you've built and tested your application, it's time to wind everything down before moving on. You can list the containers that you are running with the list command:

$ docker container list

Now that you know the container IDs, you can use docker container stop and docker container rm, as demonstrated in the previous modules.

Stop the CockroachDB and docker-gs-ping-roach containers before moving on.

Better productivity with Docker Compose

At this point, you might be wondering if there is a way to avoid having to deal with long lists of arguments to the docker command. The toy example you used in this series requires five environment variables to define the connection to the database. A real application might need many, many more. Then there is also a question of dependencies. Ideally, you want to make sure that the database is started before your application is run. And spinning up the database instance may require another Docker command with many options. But there is a better way to orchestrate these deployments for local development purposes.

In this section, you'll create a Docker Compose file to start your docker-gs-ping-roach application and CockroachDB database engine with a single command.

Configure Docker Compose

In your application's directory, create a new text file named compose.yaml with the following content.

services:
  docker-gs-ping-roach:
    depends_on:
      - roach
    build:
      context: .
    container_name: rest-server
    hostname: rest-server
    networks:
      - mynet
    ports:
      - 80:8080
    environment:
      - PGUSER=${PGUSER:-totoro}
      - PGPASSWORD=${PGPASSWORD:?database password not set}
      - PGHOST=${PGHOST:-db}
      - PGPORT=${PGPORT:-26257}
      - PGDATABASE=${PGDATABASE:-mydb}
    deploy:
      restart_policy:
        condition: on-failure
  roach:
    image: cockroachdb/cockroach:latest-v25.4
    container_name: roach
    hostname: db
    networks:
      - mynet
    ports:
      - 26257:26257
      - 8080:8080
    volumes:
      - roach:/cockroach/cockroach-data
    command: start-single-node --insecure

volumes:
  roach:

networks:
  mynet:
    driver: bridge

This Docker Compose configuration is super convenient as you don't have to type all the parameters to pass to the docker run command. You can declaratively do that in the Docker Compose file. The Docker Compose documentation pages are quite extensive and include a full reference for the Docker Compose file format.

The .env file

Docker Compose will automatically read environment variables from a .env file if it's available. Since your Compose file requires PGPASSWORD to be set, add the following content to the .env file:

PGPASSWORD=whatever

The exact value doesn't really matter for this example, because you run CockroachDB in insecure mode. Make sure you set the variable to some value to avoid getting an error.

Merging Compose files

The filename compose.yaml is the default filename which docker compose command recognizes if no -f flag is provided. This means you can have multiple Docker Compose files if your environment has such requirements. Furthermore, Docker Compose files are composable, so multiple files can be specified on the command line to merge parts of the configuration together. The following list shows a few examples of scenarios where such a feature would be useful:

You aren't going to cover any of these advanced use cases here.

Variable substitution in Docker Compose

One of the really cool features of Docker Compose is variable substitution. You can see some examples in the Compose file, environment section. By means of an example:

Other ways of dealing with undefined or empty values exist, as documented in the variable substitution section of the Docker documentation.

Validating Docker Compose configuration

Before you apply changes made to a Compose configuration file, there is an opportunity to validate the content of the configuration file with the following command:

$ docker compose config

When this command is run, Docker Compose reads the file compose.yaml, parses it into a data structure in memory, validates where possible, and prints back the reconstruction of that configuration file from its internal representation. If this isn't possible due to errors, Docker prints an error message instead.

Build and run the application using Docker Compose

Start your application and confirm that it's running.

$ docker compose up --build

You passed the --build flag so Docker will compile your image and then start it.

[!NOTE]

Docker Compose is a useful tool, but it has its own quirks. For example, no rebuild is triggered on the update to the source code unless the --build flag is provided. It is a very common pitfall to edit one's source code, and forget to use the --build flag when running docker compose up.

Since your set-up is now run by Docker Compose, it has assigned it a project name, so you get a new volume for your CockroachDB instance. This means that your application will fail to connect to the database, because the database doesn't exist in this new volume. The terminal displays an authentication error for the database:

# ... omitted output ...
rest-server             | 2021/05/10 00:54:25 failed to initialise the store: pq: password authentication failed for user totoro
roach                   | *
roach                   | * INFO: Replication was disabled for this cluster.
roach                   | * When/if adding nodes in the future, update zone configurations to increase the replication factor.
roach                   | *
roach                   | CockroachDB node starting at 2021-05-10 00:54:26.398177 +0000 UTC (took 3.0s)
roach                   | build:               CCL v20.1.15 @ 2021/04/26 16:11:58 (go1.13.9)
roach                   | webui:               http://db:8080
roach                   | sql:                 postgresql://root@db:26257?sslmode=disable
roach                   | RPC client flags:    /cockroach/cockroach <client cmd> --host=db:26257 --insecure
roach                   | logs:                /cockroach/cockroach-data/logs
roach                   | temp dir:            /cockroach/cockroach-data/cockroach-temp349434348
roach                   | external I/O path:   /cockroach/cockroach-data/extern
roach                   | store[0]:            path=/cockroach/cockroach-data
roach                   | storage engine:      rocksdb
roach                   | status:              initialized new cluster
roach                   | clusterID:           b7b1cb93-558f-4058-b77e-8a4ddb329a88
roach                   | nodeID:              1
rest-server exited with code 0
rest-server             | 2021/05/10 00:54:25 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:26 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:29 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:25 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:26 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:29 failed to initialise the store: pq: password authentication failed for user totoro
rest-server exited with code 1
# ... omitted output ...

Because of the way you set up your deployment using restart_policy, the failing container is being restarted every 20 seconds. So, in order to fix the problem, you need to log in to the database engine and create the user. You've done it before in Configure the database engine.

This isn't a big deal. All you have to do is to connect to CockroachDB instance and run the three SQL commands to create the database and the user, as described in Configure the database engine.

So, log in to the database engine from another terminal:

$ docker exec -it roach ./cockroach sql --insecure

And run the same commands as before to create the database mydb, the user totoro, and to grant that user necessary permissions. Once you do that (and the example application container is automatically restarts), the rest-service stops failing and restarting and the console goes quiet.

It would have been possible to connect the volume that you had previously used, but for the purposes of this example it's more trouble than it's worth and it also provided an opportunity to show how to introduce resilience into your deployment via the restart_policy Compose file feature.

Testing the application

Now, test your API endpoint. In the new terminal, run the following command:

$ curl http://localhost/

You should receive the following response:

Hello, Docker! (0)

Shutting down

To stop the containers started by Docker Compose, press ctrl+c in the terminal where you ran docker compose up. To remove those containers after they've been stopped, run docker compose down.

Detached mode

You can run containers started by the docker compose command in detached mode, just as you would with the docker command, by using the -d flag.

To start the stack, defined by the Compose file in detached mode, run:

$ docker compose up --build -d

Then, you can use docker compose stop to stop the containers and docker compose down to remove them.

Further exploration

You can run docker compose to see what other commands are available.

Wrap up

There are some tangential, yet interesting points that were purposefully not covered in this chapter. For the more adventurous reader, this section offers some pointers for further study.

Persistent storage

A managed volume isn't the only way to provide your container with persistent storage. It is highly recommended to get acquainted with available storage options and their use cases, covered in Manage data in Docker.

CockroachDB clusters

You ran a single instance of CockroachDB, which was enough for this example. But, it's possible to run a CockroachDB cluster, which is made of multiple instances of CockroachDB, each instance running in its own container. Since CockroachDB engine is distributed by design, it would have taken surprisingly little change to your procedure to run a cluster with multiple nodes.

Such distributed set-up offers interesting possibilities, such as applying Chaos Engineering techniques to simulate parts of the cluster failing and evaluating your application's ability to cope with such failures.

If you are interested in experimenting with CockroachDB clusters, check out:

Other databases

Since you didn't run a cluster of CockroachDB instances, you might be wondering whether you could have used a non-distributed database engine. The answer is 'yes', and if you were to pick a more traditional SQL database, such as PostgreSQL, the process described in this chapter would have been very similar.

Run your tests using Go test

Prerequisites

Complete the Build your Go image section of this guide.

Overview

Testing is an essential part of modern software development. Testing can mean a lot of things to different development teams. There are unit tests, integration tests and end-to-end testing. In this guide you take a look at running your unit tests in Docker when building.

For this section, use the docker-gs-ping project that you cloned in Build your Go image.

Run tests when building

To run your tests when building, you need to add a test stage to the Dockerfile.multistage. The Dockerfile.multistage in the sample application's repository already has the following content:

# syntax=docker/dockerfile:1

# Build the application from source
FROM golang:1.19 AS build-stage

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY *.go ./

RUN CGO_ENABLED=0 GOOS=linux go build -o /docker-gs-ping

# Run the tests in the container
FROM build-stage AS run-test-stage
RUN go test -v ./...

# Deploy the application binary into a lean image
FROM gcr.io/distroless/base-debian12 AS build-release-stage

WORKDIR /

COPY --from=build-stage /docker-gs-ping /docker-gs-ping

EXPOSE 8080

USER nonroot:nonroot

ENTRYPOINT ["/docker-gs-ping"]

Run the following command to build an image using the run-test-stage stage as the target and view the test results. Include --progress plain to view the build output, --no-cache to ensure the tests always run, and --target run-test-stage to target the test stage.

$ docker build -f Dockerfile.multistage -t docker-gs-ping-test --progress plain --no-cache --target run-test-stage .

You should see output containing the following.

#13 [run-test-stage 1/1] RUN go test -v ./...
#13 4.915 === RUN   TestIntMinBasic
#13 4.915 --- PASS: TestIntMinBasic (0.00s)
#13 4.915 === RUN   TestIntMinTableDriven
#13 4.915 === RUN   TestIntMinTableDriven/0,1
#13 4.915 === RUN   TestIntMinTableDriven/1,0
#13 4.915 === RUN   TestIntMinTableDriven/2,-2
#13 4.915 === RUN   TestIntMinTableDriven/0,-1
#13 4.915 === RUN   TestIntMinTableDriven/-1,0
#13 4.915 --- PASS: TestIntMinTableDriven (0.00s)
#13 4.915     --- PASS: TestIntMinTableDriven/0,1 (0.00s)
#13 4.915     --- PASS: TestIntMinTableDriven/1,0 (0.00s)
#13 4.915     --- PASS: TestIntMinTableDriven/2,-2 (0.00s)
#13 4.915     --- PASS: TestIntMinTableDriven/0,-1 (0.00s)
#13 4.915     --- PASS: TestIntMinTableDriven/-1,0 (0.00s)
#13 4.915 PASS

Connect Gemini to Grafana via MCP

This guide shows how to connect Gemini CLI to a Grafana instance using the Docker MCP Toolkit.

Prerequisites

Step 1: Provision Grafana access

The MCP server requires a Service Account Token to interact with the Grafana API. Service Account Tokens are preferred over personal API keys because they can be revoked independently without affecting user access, and permissions can be scoped more narrowly.

  1. Navigate to Administration > Users and access > Service accounts in your Grafana dashboard.
  2. Create a new Service Account (e.g., gemini-mcp-connector).
  3. Assign the Viewer role (or Editor if you require alert management capabilities).
  4. Generate a new token. Copy the token immediately — you won't be able to view it again.

Step 2: Configure the MCP server

The Docker MCP Toolkit provides a pre-configured Grafana catalog item. This connects the LLM to the Grafana API.

  1. Open the MCP Toolkit in Docker Desktop.
  2. Locate Grafana in the Catalog and add it to your active servers.
  3. In the Configuration view, define the following:

Step 3: Integrate Gemini CLI

To register the Docker MCP gateway within Gemini, update your global configuration file located at ~/.gemini/settings.json.

Ensure the mcpServers object includes the following entry:

{
  "mcpServers": {
    "MCP_DOCKER": {
      "command": "docker",
      "args": ["mcp", "gateway", "run"]
    }
  }
}

Step 4: Validate the setup

Restart your Gemini CLI session to load the new configuration. Verify the status of the MCP tools by running:

> /mcp list

A successful connection will show MCP_DOCKER as Ready, exposing dozens tools for data fetching, dashboard searching, and alert inspection.

Use Cases

Data source Discovery

List all Prometheus and Loki data sources.

List data sources; permission prompt

List data sources; result

Logs Inspection

Gemini performs intent parsing and translates the user's request into a precise LogQL query: {device_name="edge-device-01"} |= "nginx". Once the system identifies Loki as the active datasource, the AI autonomously constructs this command to bridge the gap between human intent and complex syntax. This query targets specific Kubernetes pod logs, extracting raw OpenTelemetry (OTel) data—including pod UIDs, container metadata, and system labels—which Gemini then uses to identify the root cause of the issue within the containerized environment.

Filter logs based on loki labels

Gemini gets the Grafana's logs from MCP docker

In the final step, Gemini performs reasoning over the raw telemetry. After filtering through hundreds of lines to confirm the existence of Nginx logs, Gemini extracts a specific node_filesystem_device_error buried within the stream. By surfacing this critical event, it alerts the DevOps engineer to a volume mounting issue on the edge node, transforming raw data into an actionable incident report.

Gemini gives an overall about the findings

Dashboard Navigation

How many dashboards do we have?

How many dashboards do we have?

Tell me the summary of X dashboard

Summary of X dashboard

Other scenarios

Imagine you get a page that an application is slow. You could:

  1. Use list_alert_rules to see which alert is firing.
  2. Use search_dashboards to find the relevant application dashboard.
  3. Use get_panel_image on a key panel to see the performance spike visually.
  4. Use query_loki_logs to search for "error" or "timeout" messages during the time of the spike.
  5. If you find the root cause, use create_incident to start the formal response and add_activity_to_incident to log your findings.

Next steps

Need help setting up your Docker MCP environment or customizing your Gemini prompts? Visit the Docker Community Forums or see the Get Started Guide.

Java language-specific guide

The Java getting started guide teaches you how to create a containerized Spring Boot application using Docker. In this module, you’ll learn how to:

After completing the Java getting started modules, you should be able to containerize your own Java application based on the examples and instructions provided in this guide.

Get started containerizing your first Java app.

Containerize a Java application

Prerequisites

Overview

This section walks you through containerizing and running a Java application.

Get the sample applications

Clone the sample application that you'll be using to your local development machine. Run the following command in a terminal to clone the repository.

$ git clone https://github.com/spring-projects/spring-petclinic.git

The sample application is a Spring Boot application built using Maven. For more details, see readme.md in the repository.

Create Docker assets

Now that you have an application, you can create the necessary Docker assets to containerize your application.

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

Create a file named Dockerfile with the following contents.

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

################################################################################

# Create a stage for resolving and downloading dependencies.
FROM eclipse-temurin:21-jdk-jammy as deps

WORKDIR /build

# Copy the mvnw wrapper with executable permissions.
COPY --chmod=0755 mvnw mvnw
COPY .mvn/ .mvn/

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.m2 so that subsequent builds don't have to
# re-download packages.
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline -DskipTests

################################################################################

# Create a stage for building the application based on the stage with downloaded dependencies.
# This Dockerfile is optimized for Java applications that output an uber jar, which includes
# all the dependencies needed to run your app inside a JVM. If your app doesn't output an uber
# jar and instead relies on an application server like Apache Tomcat, you'll need to update this
# stage with the correct filename of your package and update the base image of the "final" stage
# use the relevant app server, e.g., using tomcat (https://hub.docker.com/_/tomcat/) as a base image.
FROM deps as package

WORKDIR /build

COPY ./src src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw package -DskipTests && \
    mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar

################################################################################

# Create a stage for extracting the application into separate layers.
# Take advantage of Spring Boot's layer tools and Docker's caching by extracting
# the packaged application into separate layers that can be copied into the final stage.
# See Spring's docs for reference:
# https://docs.spring.io/spring-boot/docs/current/reference/html/container-images.html
FROM package as extract

WORKDIR /build

RUN java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted

################################################################################

# Create a new stage for running the application that contains the minimal
# runtime dependencies for the application. This often uses a different base
# image from the install or build stage where the necessary files are copied
# from the install stage.
#
# The example below uses eclipse-turmin's JRE image as the foundation for running the app.
# By specifying the "17-jre-jammy" tag, it will also use whatever happens to be the
# most recent version of that tag when you build your Dockerfile.
# If reproducibility is important, consider using a specific digest SHA, like
# eclipse-temurin@sha256:99cede493dfd88720b610eb8077c8688d3cca50003d76d1d539b0efc8cca72b4.
FROM eclipse-temurin:21-jre-jammy AS final

# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser
USER appuser

# Copy the executable from the "package" stage.
COPY --from=extract build/target/extracted/dependencies/ ./
COPY --from=extract build/target/extracted/spring-boot-loader/ ./
COPY --from=extract build/target/extracted/snapshot-dependencies/ ./
COPY --from=extract build/target/extracted/application/ ./

EXPOSE 8080

ENTRYPOINT [ "java", "org.springframework.boot.loader.launch.JarLauncher" ]

[!NOTE] The sample repository includes a docker-compose.yml file. The following instructions use the preferred compose.yaml filename — both are supported by Docker Compose.

Create a file named compose.yaml with the following contents.

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  server:
    build:
      context: .
    ports:
      - 8080:8080
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
#     depends_on:
#       db:
#         condition: service_healthy
#   db:
#     image: postgres:18
#     restart: always
#     user: postgres
#     secrets:
#       - db-password
#     volumes:
#       - db-data:/var/lib/postgresql
#     environment:
#       - POSTGRES_DB=example
#       - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
#     expose:
#       - 5432
#     healthcheck:
#       test: [ "CMD", "pg_isready" ]
#       interval: 10s
#       timeout: 5s
#       retries: 5
# volumes:
#   db-data:
# secrets:
#   db-password:
#     file: db/password.txt

Create a file named .dockerignore with the following contents.

# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.y*ml
**/target
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/vendor
LICENSE
README.md

You should now have the following three files in your spring-petclinic directory.

Run the application

Inside the spring-petclinic directory, run the following command in a terminal.

$ docker compose up --build

The first time you build and run the app, Docker downloads dependencies and builds the app. It may take several minutes depending on your network connection.

Open a browser and view the application at http://localhost:8080. You should see a simple app for a pet clinic.

In the terminal, press ctrl+c to stop the application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the spring-petclinic directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:8080. You should see a simple app for a pet clinic.

In the terminal, run the following command to stop the application.

$ docker compose down

For more information about Compose commands, see the Compose CLI reference.

Use containers for Java development

Prerequisites

Work through the steps to containerize your application in Containerize your app.

Overview

In this section, you’ll walk through setting up a local development environment for the application you containerized in the previous section. This includes:

Add a local database and persist data

You can use containers to set up local services, like a database. In this section, you'll update the docker-compose.yaml file to define a database service and a volume to persist data. Also, this particular application uses a system property to define the database type, so you'll need to update the Dockerfile to pass in the system property when starting the app.

In the cloned repository's directory, open the docker-compose.yaml file in an IDE or text editor. Your Compose file has an example database service, but it'll require a few changes for your unique app.

In the docker-compose.yaml file, you need to do the following:

The following is the updated docker-compose.yaml file. All comments have been removed.

services:
  server:
    build:
      context: .
    ports:
      - 8080:8080
    depends_on:
      db:
        condition: service_healthy
    environment:
      - POSTGRES_URL=jdbc:postgresql://db:5432/petclinic
  db:
    image: postgres:18
    restart: always
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=petclinic
      - POSTGRES_USER=petclinic
      - POSTGRES_PASSWORD=petclinic
    ports:
      - 5432:5432
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "petclinic"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:

Open the Dockerfile in an IDE or text editor. In the ENTRYPOINT instruction, update the instruction to pass in the system property as specified in the spring-petclinic/src/resources/db/postgres/petclinic_db_setup_postgres.txt file.

- ENTRYPOINT [ "java", "org.springframework.boot.loader.launch.JarLauncher" ]
+ ENTRYPOINT [ "java", "-Dspring.profiles.active=postgres", "org.springframework.boot.loader.launch.JarLauncher" ]

Save and close all the files.

Now, run the following docker compose up command to start your application.

$ docker compose up --build

Open a browser and view the application at http://localhost:8080. You should see a simple app for a pet clinic. Browse around the application. Navigate to Veterinarians and verify that the application is connected to the database by being able to list veterinarians.

In the terminal, press ctrl+c to stop the application.

Dockerfile for development

The Dockerfile you have now is great for a small, secure production image with only the components necessary to run the application. When developing, you may want a different image that has a different environment.

For example, in the development image you may want to set up the image to start the application so that you can connect a debugger to the running Java process.

Rather than managing multiple Dockerfiles, you can add a new stage. Your Dockerfile can then produce a final image which is ready for production as well as a development image.

Replace the contents of your Dockerfile with the following.

# syntax=docker/dockerfile:1

FROM eclipse-temurin:21-jdk-jammy as deps
WORKDIR /build
COPY --chmod=0755 mvnw mvnw
COPY .mvn/ .mvn/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline -DskipTests

FROM deps as package
WORKDIR /build
COPY ./src src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw package -DskipTests && \
    mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar

FROM package as extract
WORKDIR /build
RUN java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted

FROM extract as development
WORKDIR /build
RUN cp -r /build/target/extracted/dependencies/. ./
RUN cp -r /build/target/extracted/spring-boot-loader/. ./
RUN cp -r /build/target/extracted/snapshot-dependencies/. ./
RUN cp -r /build/target/extracted/application/. ./
ENV JAVA_TOOL_OPTIONS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000
CMD [ "java", "-Dspring.profiles.active=postgres", "org.springframework.boot.loader.launch.JarLauncher" ]

FROM eclipse-temurin:21-jre-jammy AS final
ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser
USER appuser
COPY --from=extract build/target/extracted/dependencies/ ./
COPY --from=extract build/target/extracted/spring-boot-loader/ ./
COPY --from=extract build/target/extracted/snapshot-dependencies/ ./
COPY --from=extract build/target/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT [ "java", "-Dspring.profiles.active=postgres", "org.springframework.boot.loader.launch.JarLauncher" ]

Save and close the Dockerfile.

In the Dockerfile you added a new stage labeled development based on the extract stage. In this stage, you copy the extracted files to a common directory, then run a command to start the application. In the command, you expose port 8000 and declare the debug configuration for the JVM so that you can attach a debugger.

Use Compose to develop locally

The current Compose file doesn't start your development container. To do that, you must update your Compose file to target the development stage. Also, update the port mapping of the server service to provide access for the debugger.

Open the docker-compose.yaml and add the following instructions into the file.

services:
  server:
    build:
      context: .
      target: development
    ports:
      - 8080:8080
      - 8000:8000
    depends_on:
      db:
        condition: service_healthy
    environment:
      - POSTGRES_URL=jdbc:postgresql://db:5432/petclinic
  db:
    image: postgres:18
    restart: always
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=petclinic
      - POSTGRES_USER=petclinic
      - POSTGRES_PASSWORD=petclinic
    ports:
      - 5432:5432
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "petclinic"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:

Now, start your application and to confirm that it's running.

$ docker compose up --build

Finally, test your API endpoint. Run the following curl command:

$ curl  --request GET \
  --url http://localhost:8080/vets \
  --header 'content-type: application/json'

You should receive the following response:

{
  "vetList": [
    {
      "id": 1,
      "firstName": "James",
      "lastName": "Carter",
      "specialties": [],
      "nrOfSpecialties": 0,
      "new": false
    },
    {
      "id": 2,
      "firstName": "Helen",
      "lastName": "Leary",
      "specialties": [{ "id": 1, "name": "radiology", "new": false }],
      "nrOfSpecialties": 1,
      "new": false
    },
    {
      "id": 3,
      "firstName": "Linda",
      "lastName": "Douglas",
      "specialties": [
        { "id": 3, "name": "dentistry", "new": false },
        { "id": 2, "name": "surgery", "new": false }
      ],
      "nrOfSpecialties": 2,
      "new": false
    },
    {
      "id": 4,
      "firstName": "Rafael",
      "lastName": "Ortega",
      "specialties": [{ "id": 2, "name": "surgery", "new": false }],
      "nrOfSpecialties": 1,
      "new": false
    },
    {
      "id": 5,
      "firstName": "Henry",
      "lastName": "Stevens",
      "specialties": [{ "id": 1, "name": "radiology", "new": false }],
      "nrOfSpecialties": 1,
      "new": false
    },
    {
      "id": 6,
      "firstName": "Sharon",
      "lastName": "Jenkins",
      "specialties": [],
      "nrOfSpecialties": 0,
      "new": false
    }
  ]
}

Connect a Debugger

You’ll use the debugger that comes with the IntelliJ IDEA. You can use the community version of this IDE. Open your project in IntelliJ IDEA, go to the Run menu, and then Edit Configuration. Add a new Remote JVM Debug configuration similar to the following:

Java Connect a Debugger

Set a breakpoint.

Open src/main/java/org/springframework/samples/petclinic/vet/VetController.java and add a breakpoint inside the showResourcesVetList function.

To start your debug session, select the Run menu and then Debug NameOfYourConfiguration.

Debug menu

You should now see the connection in the logs of your Compose application.

Compose log file

You can now call the server endpoint.

$ curl --request GET --url http://localhost:8080/vets

You should have seen the code break on the marked line and now you are able to use the debugger just like you would normally. You can also inspect and watch variables, set conditional breakpoints, view stack traces and a do bunch of other stuff.

Debugger code breakpoint

Press ctrl+c in the terminal to stop your application.

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Open your docker-compose.yaml file in an IDE or text editor and then add the Compose Watch instructions. The following is the updated docker-compose.yaml file.

services:
  server:
    build:
      context: .
      target: development
    ports:
      - 8080:8080
      - 8000:8000
    depends_on:
      db:
        condition: service_healthy
    environment:
      - POSTGRES_URL=jdbc:postgresql://db:5432/petclinic
    develop:
      watch:
        - action: rebuild
          path: .
  db:
    image: postgres:18
    restart: always
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=petclinic
      - POSTGRES_USER=petclinic
      - POSTGRES_PASSWORD=petclinic
    ports:
      - 5432:5432
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "petclinic"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:

Run the following command to run your application with Compose Watch.

$ docker compose watch

Open a web browser and view the application at http://localhost:8080. You should see the Spring Pet Clinic home page.

Any changes to the application's source files on your local machine will now be automatically reflected in the running container.

Open spring-petclinic/src/main/resources/templates/fragments/layout.html in an IDE or text editor and update the Home navigation string by adding an exclamation mark.

-   <li th:replace="~{::menuItem ('/','home','home page','home','Home')}">
+   <li th:replace="~{::menuItem ('/','home','home page','home','Home!')}">

Save the changes to layout.html and then you can continue developing while the container automatically rebuilds.

After the container is rebuilt and running, refresh http://localhost:8080 and then verify that Home! now appears in the menu.

Press ctrl+c in the terminal to stop Compose Watch.

Run your Java tests

Prerequisites

Complete all the previous sections of this guide, starting with Containerize a Java application.

Overview

Testing is an essential part of modern software development. Testing can mean a lot of things to different development teams. There are unit tests, integration tests and end-to-end testing. In this guide you'll take a look at running your unit tests in Docker.

Multi-stage Dockerfile for testing

In the following example, you'll pull the testing commands into your Dockerfile. Replace the contents of your Dockerfile with the following.

# syntax=docker/dockerfile:1

FROM eclipse-temurin:21-jdk-jammy as base
WORKDIR /build
COPY --chmod=0755 mvnw mvnw
COPY .mvn/ .mvn/

FROM base as test
WORKDIR /build
COPY ./src src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw test

FROM base as deps
WORKDIR /build
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw dependency:go-offline -DskipTests

FROM deps as package
WORKDIR /build
COPY ./src src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
    --mount=type=cache,target=/root/.m2 \
    ./mvnw package -DskipTests && \
    mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar

FROM package as extract
WORKDIR /build
RUN java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted

FROM extract as development
WORKDIR /build
RUN cp -r /build/target/extracted/dependencies/. ./
RUN cp -r /build/target/extracted/spring-boot-loader/. ./
RUN cp -r /build/target/extracted/snapshot-dependencies/. ./
RUN cp -r /build/target/extracted/application/. ./
ENV JAVA_TOOL_OPTIONS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000"
CMD [ "java", "-Dspring.profiles.active=postgres", "org.springframework.boot.loader.launch.JarLauncher" ]

FROM eclipse-temurin:21-jre-jammy AS final
ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser
USER appuser
COPY --from=extract build/target/extracted/dependencies/ ./
COPY --from=extract build/target/extracted/spring-boot-loader/ ./
COPY --from=extract build/target/extracted/snapshot-dependencies/ ./
COPY --from=extract build/target/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT [ "java", "-Dspring.profiles.active=postgres", "org.springframework.boot.loader.launch.JarLauncher" ]

First, you added a new base stage. In the base stage, you added common instructions that both the test and deps stage will need.

Next, you added a new test stage labeled test based on the base stage. In this stage you copied in the necessary source files and then specified RUN to run ./mvnw test. Instead of using CMD, you used RUN to run the tests. The reason is that the CMD instruction runs when the container runs, and the RUN instruction runs when the image is being built. When using RUN, the build will fail if the tests fail.

Finally, you updated the deps stage to be based on the base stage and removed the instructions that are now in the base stage.

Run the following command to build a new image using the test stage as the target and view the test results. Include --progress=plain to view the build output, --no-cache to ensure the tests always run, and --target test to target the test stage.

Now, build your image and run your tests. You'll run the docker build command and add the --target test flag so that you specifically run the test build stage.

$ docker build -t java-docker-image-test --progress=plain --no-cache --target=test .

You should see output containing the following

...

#15 101.3 [WARNING] Tests run: 45, Failures: 0, Errors: 0, Skipped: 2
#15 101.3 [INFO]
#15 101.3 [INFO] ------------------------------------------------------------------------
#15 101.3 [INFO] BUILD SUCCESS
#15 101.3 [INFO] ------------------------------------------------------------------------
#15 101.3 [INFO] Total time:  01:39 min
#15 101.3 [INFO] Finished at: 2024-02-01T23:24:48Z
#15 101.3 [INFO] ------------------------------------------------------------------------
#15 DONE 101.4s

Data science with JupyterLab

Docker and JupyterLab are two powerful tools that can enhance your data science workflow. In this guide, you will learn how to use them together to create and run reproducible data science environments. This guide is based on Supercharging AI/ML Development with JupyterLab and Docker.

In this guide, you'll learn how to:

What is JupyterLab?

JupyterLab is an open source application built around the concept of a computational notebook document. It enables sharing and executing code, data processing, visualization, and offers a range of interactive features for creating graphs.

Why use Docker and JupyterLab together?

By combining Docker and JupyterLab, you can benefit from the advantages of both tools, such as:

Prerequisites

To follow along with this guide, you must install the latest version of Docker Desktop.

Run and access a JupyterLab container

In a terminal, run the following command to run your JupyterLab container.

$ docker run --rm -p 8889:8888 quay.io/jupyter/base-notebook start-notebook.py --NotebookApp.token='my-token'

The following are the notable parts of the command:

For more details, see the Jupyter Server Options and the docker run CLI reference.

If this is the first time you are running the image, Docker will download and run it. The amount of time it takes to download the image will vary depending on your network connection.

After the image downloads and runs, you can access the container. To access the container, in a web browser navigate to localhost:8889/lab?token=my-token.

To stop the container, in the terminal press ctrl+c.

To access an existing notebook on your system, you can use a bind mount. Open a terminal and change directory to where your existing notebook is. Then, run the following command based on your operating system.

{{< tabs >}} {{< tab name="Mac / Linux" >}}

$ docker run --rm -p 8889:8888 -v "$(pwd):/home/jovyan/work" quay.io/jupyter/base-notebook start-notebook.py --NotebookApp.token='my-token'

{{< /tab >}} {{< tab name="Windows (Command Prompt)" >}}

$ docker run --rm -p 8889:8888 -v "%cd%":/home/jovyan/work quay.io/jupyter/base-notebook start-notebook.py --NotebookApp.token='my-token'

{{< /tab >}} {{< tab name="Windows (PowerShell)" >}}

$ docker run --rm -p 8889:8888 -v "$(pwd):/home/jovyan/work" quay.io/jupyter/base-notebook start-notebook.py --NotebookApp.token='my-token'

{{< /tab >}} {{< tab name="Windows (Git Bash)" >}}

$ docker run --rm -p 8889:8888 -v "/$(pwd):/home/jovyan/work" quay.io/jupyter/base-notebook start-notebook.py --NotebookApp.token='my-token'

{{< /tab >}} {{< /tabs >}}

The -v option tells Docker to mount your current working directory to /home/jovyan/work inside the container. By default, the Jupyter image's root directory is /home/jovyan and you can only access or save notebooks to that directory in the container.

Now you can access localhost:8889/lab?token=my-token and open notebooks contained in the bind mounted directory.

To stop the container, in the terminal press ctrl+c.

Docker also has volumes, which are the preferred mechanism for persisting data generated by and used by Docker containers. While bind mounts are dependent on the directory structure and OS of the host machine, volumes are completely managed by Docker.

Save and access notebooks

When you remove a container, all data in that container is deleted. To save notebooks outside of the container, you can use a volume.

Run a JupyterLab container with a volume

To start the container with a volume, open a terminal and run the following command

$ docker run --rm -p 8889:8888 -v jupyter-data:/home/jovyan/work quay.io/jupyter/base-notebook start-notebook.py --NotebookApp.token='my-token'

The -v option tells Docker to create a volume named jupyter-data and mount it in the container at /home/jovyan/work.

To access the container, in a web browser navigate to localhost:8889/lab?token=my-token. Notebooks can now be saved to the volume and will accessible even when the container is deleted.

Save a notebook to the volume

For this example, you'll use the Iris Dataset example from scikit-learn.

  1. Open a web browser and access your JupyterLab container at localhost:8889/lab?token=my-token.

  2. In the Launcher, under Notebook, select Python 3.

  3. In the notebook, specify the following to install the necessary packages.

    !pip install matplotlib scikit-learn
    
  4. Select the play button to run the code.

  5. In the notebook, specify the following code.

    from sklearn import datasets
    
    iris = datasets.load_iris()
    import matplotlib.pyplot as plt
    
    _, ax = plt.subplots()
    scatter = ax.scatter(iris.data[:, 0], iris.data[:, 1], c=iris.target)
    ax.set(xlabel=iris.feature_names[0], ylabel=iris.feature_names[1])
    _ = ax.legend(
       scatter.legend_elements()[0], iris.target_names, loc="lower right", title="Classes"
    )
    
  6. Select the play button to run the code. You should see a scatter plot of the Iris dataset.

  7. In the top menu, select File and then Save Notebook.

  8. Specify a name in the work directory to save the notebook to the volume. For example, work/mynotebook.ipynb.

  9. Select Rename to save the notebook.

The notebook is now saved in the volume.

In the terminal, press ctrl+ c to stop the container.

Now, any time you run a Jupyter container with the volume, you'll have access to the saved notebook.

When you do run a new container, and then run the data plot code again, it'll need to run !pip install matplotlib scikit-learn and download the packages. You can avoid reinstalling packages every time you run a new container by creating your own image with the packages already installed.

Customize your JupyterLab environment

You can create your own JupyterLab environment and build it into an image using Docker. By building your own image, you can customize your JupyterLab environment with the packages and tools you need, and ensure that it's consistent and reproducible across different deployments. Building your own image also makes it easier to share your JupyterLab environment with others, or to use it as a base for further development.

Define your environment in a Dockerfile

In the previous Iris Dataset example from Save a notebook to the volume, you had to install the dependencies, matplotlib and scikit-learn, every time you ran a new container. While the dependencies in that small example download and install quickly, it may become a problem as your list of dependencies grow. There may also be other tools, packages, or files that you always want in your environment.

In this case, you can install the dependencies as part of the environment in the image. Then, every time you run your container, the dependencies will always be installed.

You can define your environment in a Dockerfile. A Dockerfile is a text file that instructs Docker how to create an image of your JupyterLab environment. An image contains everything you want and need when running JupyterLab, such as files, packages, and tools.

In a directory of your choice, create a new text file named Dockerfile. Open the Dockerfile in an IDE or text editor and then add the following contents.

# syntax=docker/dockerfile:1

FROM quay.io/jupyter/base-notebook
RUN pip install --no-cache-dir matplotlib scikit-learn

This Dockerfile uses the quay.io/jupyter/base-notebook image as the base, and then runs pip to install the dependencies. For more details about the instructions in the Dockerfile, see the Dockerfile reference.

Before you proceed, save your changes to the Dockerfile.

Build your environment into an image

After you have a Dockerfile to define your environment, you can use docker build to build an image using your Dockerfile.

Open a terminal, change directory to the directory where your Dockerfile is located, and then run the following command.

$ docker build -t my-jupyter-image .

The command builds a Docker image from your Dockerfile and a context. The -t option specifies the name and tag of the image, in this case my-jupyter-image. The . indicates that the current directory is the context, which means that the files in that directory can be used in the image creation process.

You can verify that the image was built by viewing the Images view in Docker Desktop, or by running the docker image ls command in a terminal. You should see an image named my-jupyter-image.

Run your image as a container

To run your image as a container, you use the docker run command. In the docker run command, you'll specify your own image name.

$ docker run --rm -p 8889:8888 my-jupyter-image start-notebook.py --NotebookApp.token='my-token'

To access the container, in a web browser navigate to localhost:8889/lab?token=my-token.

You can now use the packages without having to install them in your notebook.

  1. In the Launcher, under Notebook, select Python 3.

  2. In the notebook, specify the following code.

    from sklearn import datasets
    
    iris = datasets.load_iris()
    import matplotlib.pyplot as plt
    
    _, ax = plt.subplots()
    scatter = ax.scatter(iris.data[:, 0], iris.data[:, 1], c=iris.target)
    ax.set(xlabel=iris.feature_names[0], ylabel=iris.feature_names[1])
    _ = ax.legend(
       scatter.legend_elements()[0], iris.target_names, loc="lower right", title="Classes"
    )
    
  3. Select the play button to run the code. You should see a scatter plot of the Iris dataset.

In the terminal, press ctrl+ c to stop the container.

Use Compose to run your container

Docker Compose is a tool for defining and running multi-container applications. In this case, the application isn't a multi-container application, but Docker Compose can make it easier to run by defining all the docker run options in a file.

Create a Compose file

To use Compose, you need a compose.yaml file. In the same directory as your Dockerfile, create a new file named compose.yaml.

Open the compose.yaml file in an IDE or text editor and add the following contents.

services:
  jupyter:
    build:
      context: .
    ports:
      - 8889:8888
    volumes:
      - jupyter-data:/home/jovyan/work
    command: start-notebook.py --NotebookApp.token='my-token'

volumes:
  jupyter-data:
    name: jupyter-data

This Compose file specifies all the options you used in the docker run command. For more details about the Compose instructions, see the Compose file reference.

Before you proceed, save your changes to the compose.yaml file.

Run your container using Compose

Open a terminal, change directory to where your compose.yaml file is located, and then run the following command.

$ docker compose up --build

This command builds your image and runs it as a container using the instructions specified in the compose.yaml file. The --build option ensures that your image is rebuilt, which is necessary if you made changes to your Dockerfile.

To access the container, in a web browser navigate to localhost:8889/lab?token=my-token.

In the terminal, press ctrl+ c to stop the container.

Share your work

By sharing your image and notebook, you create a portable and replicable research environment that can be easily accessed and used by other data scientists. This process not only facilitates collaboration but also ensures that your work is preserved in an environment where it can be run without compatibility issues.

To share your image and data, you'll use Docker Hub. Docker Hub is a cloud-based registry service that lets you share and distribute container images.

Share your image

  1. Sign up or sign in to Docker Hub.

  2. Rename your image so that Docker knows which repository to push it to. Open a terminal and run the following docker tag command. Replace YOUR-USER-NAME with your Docker ID.

    $ docker tag my-jupyter-image YOUR-USER-NAME/my-jupyter-image
    
  3. Run the following docker push command to push the image to Docker Hub. Replace YOUR-USER-NAME with your Docker ID.

    $ docker push YOUR-USER-NAME/my-jupyter-image
    
  4. Verify that you pushed the image to Docker Hub.

    1. Go to Docker Hub.
    2. Select My Hub > Repositories.
    3. View the Last pushed time for your repository.

Other users can now download and run your image using the docker run command. They need to replace YOUR-USER-NAME with your Docker ID.

$ docker run --rm -p 8889:8888 YOUR-USER-NAME/my-jupyter-image start-notebook.py --NotebookApp.token='my-token'

Share your volume

This example uses the Docker Desktop graphical user interface. Alternatively, in the command line interface you can back up the volume and then push it using the ORAS CLI.

  1. Sign in to Docker Desktop.
  2. In the Docker Dashboard, select Volumes.
  3. Select the jupyter-data volume by selecting the name.
  4. Select the Exports tab.
  5. Select Quick export.
  6. For Location, select Registry.
  7. In the text box under Registry, specify your Docker ID, a name for the volume, and a tag. For example, YOUR-USERNAME/jupyter-data:latest.
  8. Select Save.
  9. Verify that you exported the volume to Docker Hub.
    1. Go to Docker Hub.
    2. Select My Hub > Repositories.
    3. View the Last pushed time for your repository.

Other users can now download and import your volume. To import the volume and then run it with your image:

  1. Sign in to Docker Desktop.
  2. In the Docker Dashboard, select Volumes.
  3. Select Create to create a new volume.
  4. Specify a name for the new volume. For this example, use jupyter-data-2.
  5. Select Create.
  6. In the list of volumes, select the jupyter-data-2 volume by selecting the name.
  7. Select Import.
  8. For Location, select Registry.
  9. In the text box under Registry, specify the same name as the repository that you exported your volume to. For example, YOUR-USERNAME/jupyter-data:latest.
  10. Select Import.
  11. In a terminal, run docker run to run your image with the imported volume. Replace YOUR-USER-NAME with your Docker ID.
$ docker run --rm -p 8889:8888 -v jupyter-data-2:/home/jovyan/work YOUR-USER-NAME/my-jupyter-image start-notebook.py --NotebookApp.token='my-token'

Summary

In this guide, you learned how to leverage Docker and JupyterLab to create reproducible data science environments, facilitating the development and sharing of data science projects. This included, running a personal JupyterLab server, customizing the environment with necessary tools and packages, and sharing notebooks and environments with other data scientists.

Developing event-driven applications with Kafka and Docker

With the rise of microservices, event-driven architectures have become increasingly popular. Apache Kafka, a distributed event streaming platform, is often at the heart of these architectures. Unfortunately, setting up and deploying your own Kafka instance for development is often tricky. Fortunately, Docker and containers make this much easier.

In this guide, you will learn how to:

  1. Use Docker to launch up a Kafka cluster
  2. Connect a non-containerized app to the cluster
  3. Connect a containerized app to the cluster
  4. Deploy Kafka-UI to help with troubleshooting and debugging

Prerequisites

The following prerequisites are required to follow along with this how-to guide:

Launching Kafka

Beginning with Kafka 3.3, the deployment of Kafka was greatly simplified by no longer requiring Zookeeper thanks to KRaft (Kafka Raft). With KRaft, setting up a Kafka instance for local development is much easier. Starting with the launch of Kafka 3.8, a new kafka-native Docker image is now available, providing a significantly faster startup and lower memory footprint.

[!TIP]

This guide will be using the apache/kafka image, as it includes many helpful scripts to manage and work with Kafka. However, you may want to use the apache/kafka-native image, as it starts more quickly and requires fewer resources.

Starting Kafka

Start a basic Kafka cluster by doing the following steps. This example will launch a cluster, exposing port 9092 onto the host to let a native-running application to connect to it.

  1. Start a Kafka container by running the following command:

    $ docker run -d --name=kafka -p 9092:9092 apache/kafka
    
  2. Once the image pulls, you’ll have a Kafka instance up and running within a second or two.

  3. The apache/kafka image ships with several helpful scripts in the /opt/kafka/bin directory. Run the following command to verify the cluster is up and running and get its cluster ID:

    $ docker exec -ti kafka /opt/kafka/bin/kafka-cluster.sh cluster-id --bootstrap-server :9092
    

    Doing so will produce output similar to the following:

    Cluster ID: 5L6g3nShT-eMCtK--X86sw
    
  4. Create a sample topic and produce (or publish) a few messages by running the following command:

    $ docker exec -ti kafka /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server :9092 --topic demo
    

    After running, you can enter a message per line. For example, enter a few messages, one per line. A few examples might be:

    First message
    

    And

    Second message
    

    Press enter to send the last message and then press ctrl+c when you’re done. The messages will be published to Kafka.

  5. Confirm the messages were published into the cluster by consuming the messages:

    $ docker exec -ti kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server :9092 --topic demo --from-beginning
    

    You should then see your messages in the output:

    First message
    Second message
    

    If you want, you can open another terminal and publish more messages and see them appear in the consumer.

    When you’re done, hit ctrl+c to stop consuming messages.

You have a locally running Kafka cluster and have validated you can connect to it.

Connecting to Kafka from a non-containerized app

Now that you’ve shown you can connect to the Kafka instance from a command line, it’s time to connect to the cluster from an application. In this example, you will use a simple Node project that uses the KafkaJS library.

Since the cluster is running locally and is exposed at port 9092, the app can connect to the cluster at localhost:9092 (since it’s running natively and not in a container right now). Once connected, this sample app will log messages it consumes from the demo topic. Additionally, when it runs in development mode, it will also create the topic if it isn’t found.

  1. If you don’t have the Kafka cluster running from the previous step, run the following command to start a Kafka instance:

    $ docker run -d --name=kafka -p 9092:9092 apache/kafka
    
  2. Clone the GitHub repository locally.

    $ git clone https://github.com/dockersamples/kafka-development-node.git
    
  3. Navigate into the project.

    cd kafka-development-node/app
    
  4. Install the dependencies using yarn.

    $ yarn install
    
  5. Start the application using yarn dev. This will set the NODE_ENV environment variable to development and use nodemon to watch for file changes.

    $ yarn dev
    
  6. With the application now running, it will log received messages to the console. In a new terminal, publish a few messages using the following command:

    $ docker exec -ti kafka /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server :9092 --topic demo
    

    And then send a message to the cluster:

    Test message
    

    Remember to press ctrl+c when you’re done to stop producing messages.

Connecting to Kafka from both containers and native apps

Now that you have an application connecting to Kafka through its exposed port, it’s time to explore what changes are needed to connect to Kafka from another container. To do so, you will now run the application out of a container instead of natively.

But before you do that, it’s important to understand how Kafka listeners work and how those listeners help clients connect.

Understanding Kafka listeners

When a client connects to a Kafka cluster, it actually connects to a “broker”. While brokers have many roles, one of them is to support load balancing of clients. When a client connects, the broker returns a set of connection URLs the client should then use for the client to connect for the producing or consuming of messages. How are these connection URLs configured?

Each Kafka instance has a set of listeners and advertised listeners. The “listeners” are what Kafka binds to and the “advertised listeners” configure how clients should connect to the cluster. The connection URLs a client receives is based on which listener a client connects to.

Defining the listeners

To help this make sense, let’s look at how Kafka needs to be configured to support two connection opportunities:

  1. Host connections (those coming through the host’s mapped port) - these will need to connect using localhost
  2. Docker connections (those coming from inside the Docker networks) - these can not connect using localhost, but the network alias (or DNS address) of the Kafka service

Since there are two different methods clients need to connect, two different listeners are required - HOST and DOCKER. The HOST listener will tell clients to connect using localhost:9092, while the DOCKER listener will inform clients to connect using kafka:9093. Notice this means Kafka is listening on both ports 9092 and 9093. But, only the host listener needs to be exposed to the host.

Diagram showing the DOCKER and HOST listeners and how they are exposed to the host and Docker networks

In order to set this up, the compose.yaml for Kafka needs some additional configuration. Once you start overriding some of the defaults, you also need to specify a few other options in order for KRaft mode to work.

services:
  kafka:
    image: apache/kafka-native
    ports:
      - "9092:9092"
    environment:
      # Configure listeners for both docker and host communication
      KAFKA_LISTENERS: CONTROLLER://localhost:9091,HOST://0.0.0.0:9092,DOCKER://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: HOST://localhost:9092,DOCKER://kafka:9093
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,DOCKER:PLAINTEXT,HOST:PLAINTEXT

      # Settings required for KRaft mode
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9091

      # Listener to use for broker-to-broker communication
      KAFKA_INTER_BROKER_LISTENER_NAME: DOCKER

      # Required for a single node cluster
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1

Give it a try using the steps below.

  1. If you have the Node app running from the previous step, go ahead and stop it by pressing ctrl+c in the terminal.

  2. If you have the Kafka cluster running from the previous section, go ahead and stop that container using the following command:

    $ docker rm -f kafka
    
  3. Start the Compose stack by running the following command at the root of the cloned project directory:

    $ docker compose up
    

    After a moment, the application will be up and running.

  4. In the stack is another service that can be used to publish messages. Open it by going to http://localhost:3000. As you type in a message and submit the form, you should see the log message of the message being received by the app.

    This helps demonstrate how a containerized approach makes it easy to add additional services to help test and troubleshoot your application.

Adding cluster visualization

Once you start using containers in your development environment, you start to realize the ease of adding additional services that are solely focused on helping development, such as visualizers and other supporting services. Since you have Kafka running, it might be helpful to visualize what’s going on in the Kafka cluster. To do so, you can run the Kafbat UI web application.

To add it to your own project (it’s already in the demo application), you only need to add the following configuration to your Compose file:

services:
  kafka-ui:
    image: kafbat/kafka-ui:main
    ports:
      - 8080:8080
    environment:
      DYNAMIC_CONFIG_ENABLED: "true"
      KAFKA_CLUSTERS_0_NAME: local
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9093
    depends_on:
      - kafka

Then, once the Compose stack starts, you can open your browser to http://localhost:8080 and navigate around to view additional details about the cluster, check on consumers, publish test messages, and more.

Testing with Kafka

If you’re interested in learning how you can integrate Kafka easily into your integration tests, check out the Testing Spring Boot Kafka Listener using Testcontainers guide. This guide will teach you how to use Testcontainers to manage the lifecycle of Kafka containers in your tests.

Conclusion

By using Docker, you can simplify the process of developing and testing event-driven applications with Kafka. Containers simplify the process of setting up and deploying the various services you need to develop. And once they’re defined in Compose, everyone on the team can benefit from the ease of use.

In case you missed it earlier, all of the sample app code can be found at dockersamples/kafka-development-node.

Lab: Building Agentic Apps with Docker

Get up and running with building agentic applications using Compose, Docker Model Runner, and the Docker MCP Gateway. This hands-on lab takes you from understanding AI models to building complete agentic applications.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-agentic-apps-with-docker" model-download="true" >}}

What you'll learn

This lab covers three core areas of agentic application development:

Models: What models are, how to interact with them, configuring Docker Model Runner in Compose, and writing code that connects to the Model Runner

Tools: Understanding tools and how they work, how MCP (Model Context Protocol) fits in, configuring the Docker MCP Gateway, and connecting to the MCP Gateway in code

Code: What agentic frameworks are, defining models and tools in a Compose file, and configuring your app to use those models and tools

Modules

# Module Description
1 Introduction Overview of agentic applications and the Docker AI stack
2 Understanding Model Interactions Learn how to interact with AI models
3 The Docker Model Runner Configure and use Docker Model Runner with Compose
4 Understanding Tools and MCP Deep dive into tools, tool calling, and MCP
5 The Docker MCP Gateway Set up and configure the MCP Gateway
6 Putting It All Together Build a complete agentic application
7 Conclusion Summary and next steps

Lab: AI Fundamentals for Developers

Get hands-on with the four core pillars of AI application development: models, prompt engineering, tool calling, and RAG. This lab runs entirely on your machine using Docker Model Runner — no API key or cloud account required.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-ai-fundamentals" model-download="true" >}}

What you'll learn

By the end of this Labspace, you will have completed the following:

Modules

# Module Description
1 Welcome & Setup Introduction to the lab and verifying your environment
2 Talking to Models Chat Completions API, message roles, and stateless model behavior
3 Prompt Engineering System prompts, few-shot examples, and structured output
4 Tool Calling Tool definitions, the agentic loop, and executing tools in code
5 Retrieval Augmented Generation (RAG) Retrieve, augment, and generate with your own knowledge base
6 Wrap-up Summary of concepts and next steps

Lab: Container Image Attestations

Prove where your container images came from and that they haven't been tampered with. This lab walks through generating SBOMs and SLSA build provenance with BuildKit, signing images with Cosign, and writing VEX statements to declare which CVEs affect your image — the techniques used to meet supply chain security requirements like NIST SSDF and EO 14028.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-attestation-basics" >}}

What you'll learn

By the end of this Labspace, you will have completed the following:

Modules

# Module Description
1 Introduction Overview of supply chain attestations and the sample Go app
2 Software Bill of Materials (SBOM) Build with --sbom=true, inspect SPDX contents, and understand scanner integration
3 Build Provenance Generate SLSA provenance and explore how multi-stage builds are recorded
4 Signing Images with Cosign Generate a key pair, sign the image, verify the signature, and learn keyless signing
5 VEX Statements Scan for CVEs, write an OpenVEX document, and attach it as a signed attestation
6 Bringing It All Together Run the complete build-sign-attest workflow and see the full supply chain picture
7 Recap Summary of skills and next steps for policy enforcement and higher SLSA levels

Lab: Building Container Images

Take a working but naïve Dockerfile and progressively improve it into a production-grade image. Each section introduces one technique, applied to a real Python Flask app, so you can see the impact directly.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-building-images" >}}

What you'll learn

By the end of this Labspace, you will have completed the following:

Modules

# Module Description
1 Welcome & Your First Build Explore the sample app and build the initial image
2 Understanding Image Layers Inspect layers with docker history and see the layer cleanup pitfall
3 Dockerfile Best Practices Fix cache ordering, add .dockerignore, and switch to a non-root user
4 Multi-Stage Builds Run tests as a build gate and use a slim base for the production stage
5 Choosing a Base Image Compare slim, Alpine, and Docker Hardened Images
6 Build Secrets Show why ARG leaks secrets and use --mount=type=secret safely
7 Wrap-up Review the complete best-practices checklist and next steps

Lab: Docker Compose Quickstart

Build a Python Flask and Redis hit-counter app using Docker Compose, starting from a bare compose.yaml and progressively adding production-quality features at each step.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-compose-quickstart" >}}

What you'll learn

By the end of this Labspace, you will have completed the following:

Modules

# Module Description
1 Introduction Tour the starter app and verify the environment
2 Defining Services Write the first compose.yaml and bring up the Flask and Redis stack
3 Health Checks & Startup Order Add a Redis healthcheck and depends_on to eliminate race conditions
4 Live Development with Watch Mode Configure watch mode to sync code changes without manual rebuilds
5 Persistence & Debugging Add a named volume so Redis data survives docker compose down
6 Using Multiple Compose Files Extract Redis into infra.yaml and compose files with include
7 Additional Commands Use config, logs -f, and exec to inspect the running stack
8 Recap Review what was built and explore next steps

Lab: Getting Started with Docker

Start from zero and learn Docker's core building blocks. You'll run pre-built containers, write a Dockerfile to package a Node.js app, build your own image, and see container immutability and isolation in action.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-container-getting-started" >}}

What you'll learn

By the end of this Labspace, you will have completed the following:

Modules

# Module Description
1 Welcome to Docker Introduction to containers and running your first hello-world container
2 Running Containers Launch Nginx, inspect logs and internals, and manage the container lifecycle
3 Building Your First Image Write a Dockerfile and build a custom image from a Node.js app
4 Running Your App Run your image, explore container isolation, and optionally push to Docker Hub
5 Wrap-up Summary of key concepts and next steps

Lab: Container-Supported Development

Use containers to run the services your app depends on — databases, caches, message queues — without installing anything locally. This lab walks through running PostgreSQL in a container, writing a compose.yaml your whole team can share, and adding a pgAdmin visualizer to the dev stack.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-container-supported-development" >}}

What you'll learn

By the end of this Labspace, you will have completed the following:

Modules

# Module Description
1 Introduction Meet the sample app and understand the container-supported development approach
2 Running a Containerized Database Start PostgreSQL, connect the app, and seed the database using bind mounts
3 Making Life Easier with Compose Replace docker run commands with a shared compose.yaml
4 Adding Dev Tools Add pgAdmin to the Compose stack for database visualization
5 Recap Review key takeaways and explore related guides

Lab: The Containerized SDLC

Build a real Node.js API, then apply containers at every stage of the software development lifecycle. You'll write a Compose file for local development, integration tests using Testcontainers, a CI/CD pipeline, and Kubernetes manifests — using the same container image throughout.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-containerized-sdlc" browserUrl="http://dockerlabs.xyz" >}}

What you'll learn

By the end of this Labspace, you will have completed the following:

Modules

# Module Description
1 Introduction: Meet the App Tour the TaskFlow API and understand the SDLC journey ahead
2 Local Dev with Docker Compose Write a compose.yaml to provision a local database and visualizer
3 Containerizing Your Dev Environment Add the app to Compose with hot-reloading via Compose Watch
4 Integration Testing with Testcontainers Write self-contained tests that start a real PostgreSQL container
5 Continuous Integration with Gitea Actions Build a pipeline that tests, builds, and pushes a container image
6 Deploying to Kubernetes Write manifests and deploy to a live k3s cluster with automated rollouts
7 The Containerized SDLC: A Recap Review the portability, consistency, and reproducibility gains

Lab: Building an AI Product Reviewer

Build a complete feedback analysis pipeline for a fictional AI product called Jarvis. You'll write Node.js code that runs local LLMs and embedding models via Docker Model Runner — no API keys, no cloud subscriptions, no data leaving your machine.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-creating-ai-product-reviewer" >}}

What you'll learn

By the end of this Labspace, you will have completed the following:

Modules

# Module Description
1 Introduction Overview of the pipeline and Docker Model Runner setup
2 Project Setup & Docker Model Runner Explore the starter project and wire up Compose model integration
3 Generating Synthetic Feedback Use the LLM to generate realistic product reviews as test data
4 Sentiment Analysis Classify reviews as positive, negative, or neutral with low-temperature generation
5 Embeddings & Semantic Clustering Group related reviews using vector embeddings and cosine similarity
6 Features & Responses Extract actionable features and generate context-aware review responses
7 Wrap-up Summary of techniques and ideas for extending the pipeline

Lab: Migrating a Node App to Docker Hardened Images

Migrate a Node.js application from a standard node:24-trixie-slim base image to a Docker Hardened Image. You'll measure the before-and-after impact on CVE count, image size, and policy compliance using Docker Scout, then explore the supply chain attestations DHI ships with every image.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-dhi-node" >}}

What you'll learn

By the end of this Labspace, you will have completed the following:

Modules

# Module Description
1 Introduction Overview of Docker Hardened Images and their security benefits
2 Setup Perform setup tasks required for the lab.
3 Analyzing the Starting Image Build the app, scan it with Docker Scout, and review failing policies
4 Migrating to DHI Rewrite the Dockerfile with multi-stage DHI build and compare results
5 DHI Attestations and Scanner Integration Inspect SBOMs, FIPS attestations, STIG scans, and export VEX for external tools

Lab: Getting Started with Docker Agent

This lab walks you through building intelligent agents with Docker Agent. You'll learn beginner agent concepts, then build sophisticated multi-agent teams that handle complex real-world tasks. Learn how to create, share, and orchestrate AI agents with Docker.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-cagent" >}}

What you'll learn

Modules

# Module Description
1 Introduction Overview of Docker Agent and intelligent agent concepts
2 Getting Started Create your first agent with Docker Agent
3 Using Built-in Tools Leverage the generic agentic tools in Docker Agent
4 Using MCP Integrate MCP servers from the MCP Toolkit
5 Sharing Agents Package and share agents via Docker Registry
6 Introduction to Sub-agents Build multi-agent systems with sub-agent orchestration
7 Conclusion Summary and next steps

Lab: Docker MCP Gateway

This lab provides a comprehensive, hands-on overview of the Docker MCP Gateway, which lets you run containerized MCP servers safely and securely. Learn how to configure, secure, and connect MCP servers to your agentic applications.

Launch the lab

{{< labspace-launch image="dockersamples/labspace-mcp-gateway" >}}

What you'll learn

Modules

# Module Description
1 Introduction Overview of the MCP Gateway and why it matters
2 Adding a Simple MCP Server Get started with a basic MCP server configuration
3 Adding a Complex MCP Server Configure MCP servers with secrets and advanced options
4 Filtering Available Tools Reduce noise and save tokens by filtering tool availability
5 Connecting MCP Gateway to Your App Integrate the MCP Gateway with agentic frameworks
6 Using a Custom MCP Server Build and run your own custom MCP server
7 Conclusion Summary and next steps

Develop and Deploy Laravel applications with Docker Compose

Acknowledgment

Docker would like to thank Sergei Shitikov for his contribution to this guide.

The demonstrated examples can be found in this GitHub repository. Docker Compose offers a straightforward approach to connecting multiple containers for Laravel, though similar setups can also be achieved using tools like Docker Swarm, Kubernetes, or individual Docker containers.

This guide is intended for educational purposes, helping developers adapt and optimize configurations for their specific use cases. Additionally, there are existing tools that support Laravel in containers:

What you’ll learn

Who’s this for?

Prerequisites for Setting Up Laravel with Docker Compose

Before you begin setting up Laravel with Docker Compose, make sure you meet the following prerequisites:

Docker and Docker Compose

You need Docker and Docker Compose installed on your system. Docker allows you to containerize applications, and Docker Compose helps you manage multi-container applications.

Basic understanding of Docker and containers

A fundamental understanding of Docker and how containers work will be helpful. If you're new to Docker, consider reviewing the Docker Overview to familiarize yourself with containerization concepts.

Basic knowledge of Laravel

This guide assumes you have a basic understanding of Laravel and PHP. Familiarity with Laravel’s command-line tools, such as Artisan, and its project structure is important for following the instructions.

Laravel Production Setup with Docker Compose

This guide demonstrates how to set up a production-ready Laravel environment using Docker and Docker Compose. This configuration is designed for streamlined, scalable, and secure Laravel application deployments.

[!NOTE] To experiment with a ready-to-run configuration, download the Laravel Docker Examples repository. It contains pre-configured setups for both development and production.

Project structure

my-laravel-app/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── docker/
│   ├── common/
│   │   └── php-fpm/
│   │       ├── Dockerfile
│   │       └── conf.d/
│   │           └── 20-status-path.conf
│   ├── development/
│   ├── production/
│   │   ├── php-fpm/
│   │   │   └── entrypoint.sh
│   │   └── nginx
│   │       ├── Dockerfile
│   │       └── nginx.conf
├── compose.dev.yaml
├── compose.prod.yaml
├── .dockerignore
├── .env
├── vendor/
├── ...

This layout represents a typical Laravel project, with Docker configurations stored in a unified docker directory. You’ll find two Compose files — compose.dev.yaml (for development) and compose.prod.yaml (for production) — to keep your environments separate and manageable.

Create a Dockerfile for PHP-FPM (production)

For production, the php-fpm Dockerfile creates an optimized image with only the PHP extensions and libraries your application needs. As demonstrated in the GitHub example, a single Dockerfile with multi-stage builds maintains consistency and reduces duplication between development and production. The following snippet shows only the production-related stages:

# Stage 1: Build environment and Composer dependencies
FROM php:8.5-fpm AS builder

# Install system dependencies and PHP extensions for Laravel with MySQL/PostgreSQL support.
# Dependencies in this stage are only required for building the final image.
# Node.js and asset building are handled in the Nginx stage, not here.
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    unzip \
    libpq-dev \
    libonig-dev \
    libssl-dev \
    libxml2-dev \
    libcurl4-openssl-dev \
    libicu-dev \
    libzip-dev \
    && docker-php-ext-install -j$(nproc) \
    pdo_mysql \
    pdo_pgsql \
    pgsql \
    intl \
    zip \
    bcmath \
    soap \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Set the working directory inside the container
WORKDIR /var/www

# Copy the entire Laravel application code into the container
# -----------------------------------------------------------
# In Laravel, `composer install` may trigger scripts
# needing access to application code.
# For example, the `post-autoload-dump` event might execute
# Artisan commands like `php artisan package:discover`. If the
# application code (including the `artisan` file) is not
# present, these commands will fail, leading to build errors.
#
# By copying the entire application code before running
# `composer install`, we ensure that all necessary files are
# available, allowing these scripts to run successfully.
# In other cases, it would be possible to copy composer files
# first, to leverage Docker's layer caching mechanism.
# -----------------------------------------------------------
COPY . /var/www

# Install Composer and dependencies
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
    && composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist

# Stage 2: Production environment
FROM php:8.5-fpm AS production

# Install only runtime libraries needed in production
# libfcgi-bin and procps are required for the php-fpm-healthcheck script
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev \
    libicu-dev \
    libzip-dev \
    libfcgi-bin \
    procps \
    && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Download and install php-fpm health check script
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
    https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
    && chmod +x /usr/local/bin/php-fpm-healthcheck

# Copy the initialization script
COPY ./docker/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

# Copy the initial storage structure
COPY ./storage /var/www/storage-init

# Copy PHP extensions and libraries from the builder stage
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/

# Use the recommended production PHP configuration
# -----------------------------------------------------------
# PHP provides development and production configurations.
# Here, we replace the default php.ini with the production
# version to apply settings optimized for performance and
# security in a live environment.
# -----------------------------------------------------------
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

# Keep the image-provided FPM global config intact and add pool overrides separately
COPY ./docker/common/php-fpm/conf.d/*.conf /usr/local/etc/php-fpm.d/
# Update the variables_order to include E (for ENV)
#RUN sed -i 's/variables_order = "GPCS"/variables_order = "EGPCS"/' "$PHP_INI_DIR/php.ini"

# Copy the application code and dependencies from the build stage
COPY --from=builder /var/www /var/www

# Set working directory
WORKDIR /var/www

# Ensure correct permissions
RUN chown -R www-data:www-data /var/www

# Switch to the non-privileged user to run the application
USER www-data

# Change the default command to run the entrypoint script
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

# Expose port 9000 and start php-fpm server
EXPOSE 9000
CMD ["php-fpm"]

Create a Dockerfile for PHP-CLI (production)

For production, you often need a separate container to run Artisan commands, migrations, and other CLI tasks. In most cases you can run these commands by reusing existing PHP-FPM container:

$ docker compose -f compose.prod.yaml exec php-fpm php artisan route:list

If you need a separate CLI container with different extensions or strict separation of concerns, consider a php-cli Dockerfile:

# Stage 1: Build environment and Composer dependencies
FROM php:8.5-cli AS builder

# Install system dependencies and PHP extensions required for Laravel + MySQL/PostgreSQL support
# Some dependencies are required for PHP extensions only in the build stage
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    unzip \
    libpq-dev \
    libonig-dev \
    libssl-dev \
    libxml2-dev \
    libcurl4-openssl-dev \
    libicu-dev \
    libzip-dev \
    && docker-php-ext-install -j$(nproc) \
    pdo_mysql \
    pdo_pgsql \
    pgsql \
    intl \
    zip \
    bcmath \
    soap \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Set the working directory inside the container
WORKDIR /var/www

# Copy the entire Laravel application code into the container
COPY . /var/www

# Install Composer and dependencies
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
    && composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist

# Stage 2: Production environment
FROM php:8.5-cli

# Install client libraries required for php extensions in runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev \
    libicu-dev \
    libzip-dev \
    && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Copy PHP extensions and libraries from the builder stage
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/

# Use the default production configuration for PHP runtime arguments
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

# Copy the application code and dependencies from the build stage
COPY --from=builder /var/www /var/www

# Set working directory
WORKDIR /var/www

# Ensure correct permissions
RUN chown -R www-data:www-data /var/www

# Switch to the non-privileged user to run the application
USER www-data

# Default command: Provide a bash shell to allow running any command
CMD ["bash"]

This Dockerfile is similar to the PHP-FPM Dockerfile, but it uses the php:8.5-cli image as the base image and sets up the container for running CLI commands.

Create a Dockerfile for Nginx (production)

Nginx serves as the web server for the Laravel application. You can include static assets directly to the container. Here's an example of possible Dockerfile for Nginx:

# docker/nginx/Dockerfile
# Stage 1: Build assets
FROM debian AS builder

# Install Node.js and build tools
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    nodejs \
    npm \
    && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Set working directory
WORKDIR /var/www

# Copy Laravel application code
COPY . /var/www

# Install Node.js dependencies and build assets
RUN npm install && npm run build

# Stage 2: Nginx production image
FROM nginx:alpine

# Copy custom Nginx configuration
# -----------------------------------------------------------
# Replace the default Nginx configuration with our custom one
# that is optimized for serving a Laravel application.
# -----------------------------------------------------------
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf

# Copy Laravel's public assets from the builder stage
# -----------------------------------------------------------
# We only need the 'public' directory from our Laravel app.
# -----------------------------------------------------------
COPY --from=builder /var/www/public /var/www/public

# Set the working directory to the public folder
WORKDIR /var/www/public

# Expose port 80 and start Nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

This Dockerfile uses a multi-stage build to separate the asset building process from the final production image. The first stage installs Node.js and builds the assets, while the second stage sets up the Nginx production image with the optimized configuration and the built assets.

Create a Docker Compose configuration for production

To bring all the services together, create a compose.prod.yaml file that defines the services, volumes, and networks for the production environment. Here's an example configuration:

services:
  web:
    build:
      context: .
      dockerfile: ./docker/production/nginx/Dockerfile
    restart: unless-stopped # Automatically restart unless the service is explicitly stopped
    volumes:
      # Mount the 'laravel-storage' volume to '/var/www/storage' inside the container.
      # -----------------------------------------------------------
      # This volume stores persistent data like uploaded files and cache.
      # The ':ro' option mounts it as read-only in the 'web' service because Nginx only needs to read these files.
      # The 'php-fpm' service mounts the same volume without ':ro' to allow write operations.
      # -----------------------------------------------------------
      - laravel-storage-production:/var/www/storage:ro
    networks:
      - laravel-production
    ports:
      # Map port 80 inside the container to the port specified by 'NGINX_PORT' on the host machine.
      # -----------------------------------------------------------
      # This allows external access to the Nginx web server running inside the container.
      # For example, if 'NGINX_PORT' is set to '8080', accessing 'http://localhost:8080' will reach the application.
      # -----------------------------------------------------------
      - "${NGINX_PORT:-80}:80"
    depends_on:
      php-fpm:
        condition: service_healthy # Wait for php-fpm health check

  php-fpm:
    # For the php-fpm service, we will create a custom image to install the necessary PHP extensions and setup proper permissions.
    build:
      context: .
      dockerfile: ./docker/common/php-fpm/Dockerfile
      target: production # Use the 'production' stage in the Dockerfile
    restart: unless-stopped
    volumes:
      - laravel-storage-production:/var/www/storage # Mount the storage volume
    env_file:
      - .env
    networks:
      - laravel-production
    healthcheck:
      test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
    # The 'depends_on' attribute with 'condition: service_healthy' ensures that
    # this service will not start until the 'postgres' service passes its health check.
    # This prevents the application from trying to connect to the database before it's ready.
    depends_on:
      postgres:
        condition: service_healthy

  # The 'php-cli' service provides a command-line interface for running Artisan commands and other CLI tasks.
  # -----------------------------------------------------------
  # This is useful for running migrations, seeders, or any custom scripts.
  # It shares the same codebase and environment as the 'php-fpm' service.
  # -----------------------------------------------------------
  php-cli:
    build:
      context: .
      dockerfile: ./docker/php-cli/Dockerfile
    tty: true # Enables an interactive terminal
    stdin_open: true # Keeps standard input open for 'docker exec'
    env_file:
      - .env
    networks:
      - laravel-production

  postgres:
    image: postgres:18
    restart: unless-stopped
    user: postgres
    ports:
      - "${POSTGRES_PORT}:5432"
    environment:
      - POSTGRES_DB=${POSTGRES_DATABASE}
      - POSTGRES_USER=${POSTGRES_USERNAME}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    volumes:
      - postgres-data-production:/var/lib/postgresql
    networks:
      - laravel-production
    # Health check for PostgreSQL
    # -----------------------------------------------------------
    # Health checks allow Docker to determine if a service is operational.
    # The 'pg_isready' command checks if PostgreSQL is ready to accept connections.
    # This prevents dependent services from starting before the database is ready.
    # -----------------------------------------------------------
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:alpine
    restart: unless-stopped # Automatically restart unless the service is explicitly stopped
    networks:
      - laravel-production
    # Health check for Redis
    # -----------------------------------------------------------
    # Checks if Redis is responding to the 'PING' command.
    # This ensures that the service is not only running but also operational.
    # -----------------------------------------------------------
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

networks:
  # Attach the service to the 'laravel-production' network.
  # -----------------------------------------------------------
  # This custom network allows all services within it to communicate using their service names as hostnames.
  # For example, 'php-fpm' can connect to 'postgres' by using 'postgres' as the hostname.
  # -----------------------------------------------------------
  laravel-production:

volumes:
  postgres-data-production:
  laravel-storage-production:

[!NOTE] Ensure you have an .env file at the root of your Laravel project with the necessary configurations to match the Docker Compose setup.

Running your production environment

To start the production environment, run:

$ docker compose -f compose.prod.yaml up --build -d

This command will build and start all the services in detached mode, providing a scalable and production-ready setup for your Laravel application.

Summary

By setting up a Docker Compose environment for Laravel in production, you ensure that your application is optimized for performance, scalable, and secure. This setup makes deployments consistent and easier to manage, reducing the likelihood of errors due to differences between environments.

Laravel Development Setup with Docker Compose

This guide demonstrates how to configure a development environment for a Laravel application using Docker and Docker Compose. It builds on top of the production image for PHP-FPM and then adds developer-focused features—like Xdebug—to streamline debugging. By basing the development container on a known production image, you keep both environments closely aligned.

This setup includes PHP-FPM, Nginx, and PostgreSQL services (although you can easily swap PostgreSQL for another database, like MySQL or MariaDB). Everything runs in containers, so you can develop in isolation without altering your host system.

[!NOTE] To experiment with a ready-to-run configuration, download the Laravel Docker Examples repository. It contains pre-configured setups for both development and production.

Project structure

my-laravel-app/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── docker/
│   ├── common/
│   │   └── php-fpm/
│   │       └── Dockerfile
│   ├── development/
│   │   ├── php-fpm/
│   │   │   └── entrypoint.sh
│   │   ├── workspace/
│   │   │   └── Dockerfile
│   │   └── nginx
│   │       ├── Dockerfile
│   │       └── nginx.conf
│   └── production/
├── compose.dev.yaml
├── compose.prod.yaml
├── .dockerignore
├── .env
├── vendor/
├── ...

This layout represents a typical Laravel project, with Docker configurations stored in a unified docker directory. You’ll find two Compose files — compose.dev.yaml (for development) and compose.prod.yaml (for production) — to keep your environments separate and manageable.

The environment includes a workspace service, a sidecar container for tasks like building front-end assets, running Artisan commands, and other CLI tools your project may require. While this extra container may seem unusual, it’s a familiar pattern in solutions like Laravel Sail and Laradock. It also includes Xdebug to aid in debugging.

Create a Dockerfile for PHP-FPM

This Dockerfile extends the production image by installing Xdebug and adjusting user permissions to ease local development. That way, your development environment stays consistent with production while still offering extra debug features and improved file mounting.

# Builds a dev-only layer on top of the production image
FROM production AS development

# Use ARGs to define environment variables passed from the Docker build command or Docker Compose.
ARG XDEBUG_ENABLED=true
ARG XDEBUG_MODE=develop,coverage,debug,profile
ARG XDEBUG_HOST=host.docker.internal
ARG XDEBUG_IDE_KEY=DOCKER
ARG XDEBUG_LOG=/dev/stdout
ARG XDEBUG_LOG_LEVEL=0

USER root

# Configure Xdebug if enabled
RUN if [ "${XDEBUG_ENABLED}" = "true" ]; then \
    pecl install xdebug && \
    docker-php-ext-enable xdebug && \
    echo "xdebug.mode=${XDEBUG_MODE}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
    echo "xdebug.idekey=${XDEBUG_IDE_KEY}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
    echo "xdebug.log=${XDEBUG_LOG}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
    echo "xdebug.log_level=${XDEBUG_LOG_LEVEL}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
    echo "xdebug.client_host=${XDEBUG_HOST}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \
    echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \
fi

# Add ARGs for syncing permissions
ARG UID=1000
ARG GID=1000

# Create a new user with the specified UID and GID, reusing an existing group if GID exists
RUN if getent group ${GID}; then \
      group_name=$(getent group ${GID} | cut -d: -f1); \
      useradd -m -u ${UID} -g ${GID} -s /bin/bash www; \
    else \
      groupadd -g ${GID} www && \
      useradd -m -u ${UID} -g www -s /bin/bash www; \
      group_name=www; \
    fi

# Dynamically update php-fpm to use the new user and group
RUN sed -i "s/user = www-data/user = www/g" /usr/local/etc/php-fpm.d/www.conf && \
    sed -i "s/group = www-data/group = $group_name/g" /usr/local/etc/php-fpm.d/www.conf


# Set the working directory
WORKDIR /var/www

# Copy the entrypoint script
COPY ./docker/development/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

# Switch back to the non-privileged user to run the application
USER www-data

# Change the default command to run the entrypoint script
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

# Expose port 9000 and start php-fpm server
EXPOSE 9000
CMD ["php-fpm"]

Create a Dockerfile for Workspace

A workspace container provides a dedicated shell for asset compilation, Artisan/Composer commands, and other CLI tasks. This approach follows patterns from Laravel Sail and Laradock, consolidating all development tools into one container for convenience.

# docker/development/workspace/Dockerfile
# Use the official PHP CLI image as the base
FROM php:8.5-cli

# Set environment variables for user and group ID
ARG UID=1000
ARG GID=1000
ARG NODE_VERSION=22.0.0

# Install system dependencies and build libraries
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    unzip \
    libpq-dev \
    libonig-dev \
    libssl-dev \
    libxml2-dev \
    libcurl4-openssl-dev \
    libicu-dev \
    libzip-dev \
    && docker-php-ext-install -j$(nproc) \
    pdo_mysql \
    pdo_pgsql \
    pgsql \
    intl \
    zip \
    bcmath \
    soap \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
    && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Use ARG to define environment variables passed from the Docker build command or Docker Compose.
ARG XDEBUG_ENABLED
ARG XDEBUG_MODE
ARG XDEBUG_HOST
ARG XDEBUG_IDE_KEY
ARG XDEBUG_LOG
ARG XDEBUG_LOG_LEVEL

# Configure Xdebug if enabled
RUN if [ "${XDEBUG_ENABLED}" = "true" ]; then \
    pecl install xdebug && \
    docker-php-ext-enable xdebug && \
    echo "xdebug.mode=${XDEBUG_MODE}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
    echo "xdebug.idekey=${XDEBUG_IDE_KEY}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
    echo "xdebug.log=${XDEBUG_LOG}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
    echo "xdebug.log_level=${XDEBUG_LOG_LEVEL}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
    echo "xdebug.client_host=${XDEBUG_HOST}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \
    echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \
fi

# If the group already exists, use it; otherwise, create the 'www' group
RUN if getent group ${GID}; then \
      useradd -m -u ${UID} -g ${GID} -s /bin/bash www; \
    else \
      groupadd -g ${GID} www && \
      useradd -m -u ${UID} -g www -s /bin/bash www; \
    fi && \
    usermod -aG sudo www && \
    echo 'www ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers

# Switch to the non-root user to install NVM and Node.js
USER www

# Install NVM (Node Version Manager) as the www user
RUN export NVM_DIR="$HOME/.nvm" && \
    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash && \
    [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && \
    nvm install ${NODE_VERSION} && \
    nvm alias default ${NODE_VERSION} && \
    nvm use default

# Ensure NVM is available for all future shells
RUN echo 'export NVM_DIR="$HOME/.nvm"' >> /home/www/.bashrc && \
    echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> /home/www/.bashrc && \
    echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"' >> /home/www/.bashrc

# Set the working directory
WORKDIR /var/www

# Override the entrypoint to avoid the default php entrypoint
ENTRYPOINT []

# Default command to keep the container running
CMD ["bash"]

[!NOTE] If you prefer a one-service-per-container approach, simply omit the workspace container and run separate containers for each task. For example, you could use a dedicated php-cli container for your PHP scripts, and a node container to handle the asset building.

Create a Docker Compose configuration for development

Here's the compose.yaml file to set up the development environment:

services:
  web:
    image: nginx:latest # Using the default Nginx image with custom configuration.
    volumes:
      # Mount the application code for live updates
      - ./:/var/www
      # Mount the Nginx configuration file
      - ./docker/development/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      # Map port 80 inside the container to the port specified by 'NGINX_PORT' on the host machine
      - "80:80"
    environment:
      - NGINX_HOST=localhost
    networks:
      - laravel-development
    depends_on:
      php-fpm:
        condition: service_started # Wait for php-fpm to start

  php-fpm:
    # For the php-fpm service, we will use our common PHP-FPM Dockerfile with the development target
    build:
      context: .
      dockerfile: ./docker/common/php-fpm/Dockerfile
      target: development
      args:
        UID: ${UID:-1000}
        GID: ${GID:-1000}
        XDEBUG_ENABLED: ${XDEBUG_ENABLED:-true}
        XDEBUG_MODE: develop,coverage,debug,profile
        XDEBUG_HOST: ${XDEBUG_HOST:-host.docker.internal}
        XDEBUG_IDE_KEY: ${XDEBUG_IDE_KEY:-DOCKER}
        XDEBUG_LOG: /dev/stdout
        XDEBUG_LOG_LEVEL: 0
    env_file:
      # Load the environment variables from the Laravel application
      - .env
    user: "${UID:-1000}:${GID:-1000}"
    volumes:
      # Mount the application code for live updates
      - ./:/var/www
    networks:
      - laravel-development
    depends_on:
      postgres:
        condition: service_started # Wait for postgres to start

  workspace:
    # For the workspace service, we will also create a custom image to install and setup all the necessary stuff.
    build:
      context: .
      dockerfile: ./docker/development/workspace/Dockerfile
      args:
        UID: ${UID:-1000}
        GID: ${GID:-1000}
        XDEBUG_ENABLED: ${XDEBUG_ENABLED:-true}
        XDEBUG_MODE: develop,coverage,debug,profile
        XDEBUG_HOST: ${XDEBUG_HOST:-host.docker.internal}
        XDEBUG_IDE_KEY: ${XDEBUG_IDE_KEY:-DOCKER}
        XDEBUG_LOG: /dev/stdout
        XDEBUG_LOG_LEVEL: 0
    tty: true # Enables an interactive terminal
    stdin_open: true # Keeps standard input open for 'docker exec'
    env_file:
      - .env
    volumes:
      - ./:/var/www
    networks:
      - laravel-development

  postgres:
    image: postgres:18
    ports:
      - "${POSTGRES_PORT:-5432}:5432"
    environment:
      - POSTGRES_DB=app
      - POSTGRES_USER=laravel
      - POSTGRES_PASSWORD=secret
    volumes:
      - postgres-data-development:/var/lib/postgresql
    networks:
      - laravel-development

  redis:
    image: redis:alpine
    networks:
      - laravel-development

networks:
  laravel-development:

volumes:
  postgres-data-development:

[!NOTE] Ensure you have an .env file at the root of your Laravel project with the necessary configurations. You can use the .env.example file as a template.

Run your development environment

To start the development environment, use:

$ docker compose -f compose.dev.yaml up --build -d

Run this command to build and start the development environment in detached mode. When the containers finish initializing, visit http://localhost/ to see your Laravel app in action.

Summary

By building on top of the production image and adding debug tools like Xdebug, you create a Laravel development workflow that closely mirrors production. The optional workspace container simplifies tasks like asset building and running Artisan commands. If you prefer a separate container for every service (e.g., a dedicated php-cli and node container), you can skip the workspace approach. Either way, Docker Compose provides an efficient, consistent way to develop your Laravel project.

Common Questions on Using Laravel with Docker

1. Why should I use Docker Compose for Laravel?

Docker Compose is a powerful tool for managing multi-container environments, particularly in development due to its simplicity. With Docker Compose, you can define and connect all necessary services for Laravel, such as PHP, Nginx, and databases, in a single configuration (compose.*.yaml). This setup ensures consistency across development, testing, and production environments, streamlining onboarding and reducing discrepancies between local and server setups.

While Docker Compose is a great choice for development, tools like Docker Swarm or Kubernetes offer advanced scaling and orchestration features, which may be beneficial for complex production deployments.

2. How do I debug my Laravel application with Docker Compose?

To debug your Laravel application in a Docker environment, use Xdebug. In the development setup, Xdebug is installed in the php-fpm container to enable debugging. Ensure Xdebug is enabled in your compose.dev.yaml file by setting the environment variable XDEBUG_ENABLED=true and configuring your IDE (e.g., Visual Studio Code or PHPStorm) to connect to the remote container for debugging.

3. Can I use Docker Compose with databases other than PostgreSQL?

Yes, Docker Compose supports various database services for Laravel. While PostgreSQL is used in the examples, you can easily substitute MySQL, MariaDB, or even SQLite. Update the compose.*.yaml file to specify the required Docker image and adjust your .env file to reflect the new database configuration.

4. How can I persist data in development and production?

In both development and production, Docker volumes are used to persist data. For instance, in the compose.*.yaml file, the postgres-data-* volume stores PostgreSQL data, ensuring that data is retained even if the container restarts. You can also define named volumes for other services where data persistence is essential.

5. What is the difference between development and production Docker configurations?

In a development environment, Docker configurations include tools that streamline coding and debugging, such as Xdebug for debugging, and volume mounts to enable real-time code updates without requiring image rebuilds.

In production, the configuration is optimized for performance, security, and efficiency. This setup uses multi-stage builds to keep the image lightweight and includes only essential tools, packages, and libraries.

Additionally, consider using Docker Scout to detect and analyze vulnerabilities, especially in production environments.

For additional information about using Docker Compose in production, see this guide.

Develop and test AWS Cloud applications using LocalStack and Docker

In modern application development, testing cloud applications locally before deploying them to a live environment helps you ship faster and with more confidence. This approach involves simulating services locally, identifying and fixing issues early, and iterating quickly without incurring costs or facing the complexities of a full cloud environment. Tools like LocalStack have become invaluable in this process, enabling you to emulate AWS services and containerize applications for consistent, isolated testing environments.

In this guide, you'll learn how to:

What is LocalStack?

LocalStack is a cloud service emulator that runs in a single container on your laptop. It provides a powerful, flexible, and cost-effective way to test and develop AWS-based applications locally.

Why use LocalStack?

Simulating AWS services locally allows you to test how your app interacts with services like S3, Lambda, and DynamoDB without needing to connect to the real AWS cloud. You can quickly iterate on your development, avoiding the cost and complexity of deploying to the cloud during this phase.

By mimicking the behavior of these services locally, LocalStack enables faster feedback loops. Your app can interact with external APIs, but everything runs locally, removing the need to deal with cloud provisioning or network latency.

This makes it easier to validate integrations and test cloud-based scenarios without needing to configure IAM roles or policies in a live environment. You can simulate complex cloud architectures locally and push your changes to AWS only when you’re ready.

Using LocalStack with Docker

The official Docker image for LocalStack provides a convenient way to run LocalStack on your development machine. It’s free to use and doesn’t require any API key to run. You can even use LocalStack Docker Extension to use LocalStack with a graphical user interface.

Prerequisites

The following prerequisites are required to follow along with this how-to guide:

Launching LocalStack

Launch a quick demo of LocalStack by using the following steps:

  1. Start by cloning a sample application. Open the terminal and run the following command:

    $ git clone https://github.com/dockersamples/todo-list-localstack-docker
    $ cd todo-list-localstack-docker
    
  2. Bring up LocalStack

    Run the following command to bring up LocalStack.

    $ docker compose -f compose-native.yml up -d
    

    This Compose file also includes specifications for a required Mongo database. You can verify the services are up and running by visiting the Docker Desktop Dashboard.

    Diagram showing the LocalStack and Mongo container up and running on Docker Desktop

  3. Verify that LocalStack is up and running by selecting the container and checking the logs.

    Diagram showing the logs of LocalStack container

  4. Creating a Local Amazon S3 Bucket

    When you create a local S3 bucket using LocalStack, you're essentially simulating the creation of an S3 bucket on AWS. This lets you to test and develop applications that interact with S3 without needing an actual AWS account.

    To create Local Amazon S3 bucket, install the awscli-local CLI on your system. The awslocal command is a thin wrapper around the AWS command line interface for use with LocalStack. It lets you to test and develop against a simulated environment on your local machine without needing to access the real AWS services.

    $ pip install awscli-local
    

    Create a new S3 bucket within the LocalStack environment with the following command:

    $ awslocal s3 mb s3://mysamplebucket
    

    The command s3 mb s3://mysamplebucket tells the AWS CLI to create a new S3 bucket (mb stands for make bucket) named mysamplebucket.

    You can verify if the S3 bucket gets created or not by selecting the LocalStack container on the Docker Desktop Dashboard and viewing the logs. The logs indicates that your LocalStack environment is configured correctly and you can now use the mysamplebucket for storing and retrieving objects.

    Diagram showing the logs of LocalStack that highlights the S3 bucket being created successfully

Using LocalStack in development

Now that you've familiarized yourself with LocalStack, it's time to see it in action. In this demonstration, you'll use a sample application featuring a React frontend and a Node.js backend. This application stack utilizes the following components:

Diagram showing the tech stack of the sample todo-list application that includes LocalStack, frontend and backend services

Connecting to LocalStack from a non-containerized app

Now it’s time to connect your app to LocalStack. The index.js file, located in the backend/ directory, serves as the main entry point for the backend application.

The code interacts with LocalStack’s S3 service, which is accessed via the endpoint defined by the S3_ENDPOINT_URL environment variable, typically set to http://localhost:4566 for local development.

The S3Client from the AWS SDK is configured to use this LocalStack endpoint, along with test credentials (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY) that are also sourced from environment variables. This setup lets the application to perform operations on the locally simulated S3 service as if it were interacting with the real AWS S3, making the code flexible for different environments.

The code uses multer and multer-s3 to handle file uploads. When a user uploads an image through the /upload route, the file is stored directly in the S3 bucket simulated by LocalStack. The bucket name is retrieved from the environment variable S3_BUCKET_NAME. Each uploaded file is given a unique name by appending the current timestamp to the original filename. The route then returns the URL of the uploaded file within the local S3 service, making it accessible just as it would be if hosted on a real AWS S3 bucket.

Let’s see it in action. Start by launching the Node.js backend service.

  1. Change to the backend/ directory

    $ cd backend/
    
  2. Install the required dependencies:

    $ npm install
    
  3. Setting up AWS environment variables

    The .env file located in the backend/ directory already contains placeholder credentials and configuration values that LocalStack uses to emulate AWS services. The AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are placeholder credentials, while S3_BUCKET_NAME and S3_ENDPOINT_URL are configuration settings. No changes are needed as these values are already correctly set for LocalStack.

    [!TIP]

    Given that you’re running Mongo in a Docker container and the backend Node app is running natively on your host, ensure that MONGODB_URI=mongodb://localhost:27017/todos is set in your .env file.

    MONGODB_URI=mongodb://localhost:27017/todos
    AWS_ACCESS_KEY_ID=test
    AWS_SECRET_ACCESS_KEY=test
    S3_BUCKET_NAME=mysamplebucket
    S3_ENDPOINT_URL=http://localhost:4566
    AWS_REGION=us-east-1
    

    While the AWS SDK might typically use environment variables starting with AWS_, this specific application directly references the following S3_* variables in the index.js file (under the backend/ directory) to configure the S3Client.

    const s3 = new S3Client({
      endpoint: process.env.S3_ENDPOINT_URL, // Use the provided endpoint or fallback to defaults
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'default_access_key', // Default values for development
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'default_secret_key',  
      },
    });
    
  4. Start the backend server:

    $ node index.js
    

    You will see the message that the backend service has successfully started at port 5000.

Start the frontend service

To start the frontend service, open a new terminal and follow these steps:

  1. Navigate to the frontend directory:

    $ cd frontend
    
  2. Install the required dependencies

    $ npm install
    
  3. Start the frontend service

    $ npm run dev
    

    By now, you should see the following message:

    VITE v5.4.2  ready in 110 ms
    ➜  Local: http://localhost:5173/
    ➜  Network: use --host to expose
    ➜  press h + enter to show help
    

    You can now access the app via http://localhost:5173. Go ahead, and upload an image by choosing an image file and clicking the Upload button.

    Diagram showing a working todo-list application

    You can verify the image is uploaded to the S3 bucket by checking the LocalStack container logs:

    Diagram showing the logs of the LocalStack that highlights image uploaded to the emulated S3 bucket

    The 200 status code signifies that the putObject operation, which involves uploading an object to the S3 bucket, was executed successfully within the LocalStack environment. LocalStack logs this entry to provide visibility into the operations being performed. It helps debug and confirm that your application is interacting correctly with the emulated AWS services.

    Since LocalStack is designed to simulate AWS services locally, this log entry shows that your application is functioning as expected when performing cloud operations in a local sandbox environment.

Connecting to LocalStack from containerized Node app

Now that you have learnt how to connect a non-containerized Node.js application to LocalStack, it's time to explore the necessary changes to run the complete application stack in a containerized environment. To achieve this, you will create a Compose file specifying all required services - frontend, backend, database, and LocalStack.

  1. Examine the Docker Compose file.

    The following Docker Compose file defines four services: backend, frontend, mongodb, and localstack. The backend and frontend services are your Node.js applications, while mongodb provides a database and localstack simulates AWS services like S3.

    The backend service depends on localstack and mongodb services, ensuring they are running before it starts. It also uses a .env file for environment variables. The frontend service depends on the backend and sets the API URL. The mongodb service uses a persistent volume for data storage, and localstack is configured to run the S3 service. This setup lets you to develop and test your application locally with AWS-like services.

    services:
      backend:
        build:
          context: ./backend
          dockerfile: Dockerfile
        ports:
          - 5000:5000
        depends_on:
          - localstack
          - mongodb
        env_file:
          - backend/.env
    
      frontend:
        build:
          context: ./frontend
          dockerfile: Dockerfile
        ports:
          - 5173:5173
        depends_on:
          - backend
        environment:
          - REACT_APP_API_URL=http://backend:5000/api
    
      mongodb:
        image: mongo
        container_name: mongodb
        volumes:
          - mongodbdata:/data/db
        ports:
          - 27017:27017
    
      localstack:
        image: localstack/localstack
        container_name: localstack
        ports:
          - 4566:4566
        environment:
          - SERVICES=s3
          - GATEWAY_LISTEN=0.0.0.0:4566
        volumes:
          - ./localstack:/etc/localstack/init/ready.d
    
    volumes:
      mongodbdata:
    
  2. Modify the .env file under the backend/ directory to have the resources connect using the internal network names.

    [!TIP] Given the previous Compose file, the app would connect to LocalStack using the hostname localstack while Mongo would connect using the hostname mongodb.

    MONGODB_URI=mongodb://mongodb:27017/todos
    AWS_ACCESS_KEY_ID=test
    AWS_SECRET_ACCESS_KEY=test
    S3_BUCKET_NAME=mysamplebucket
    S3_ENDPOINT_URL=http://localstack:4566
    AWS_REGION=us-east-1
    
  3. Stop the running services

    Ensure that you stop the Node frontend and backend service from the previous step by pressing “Ctrl+C” in the terminal. Also, you'll need to stop the LocalStack and Mongo containers by selecting them in the Docker Desktop Dashboard and selecting the "Delete" button.

  4. Start the application stack by executing the following command at the root of your cloned project directory:

    $ docker compose -f compose.yml up -d --build
    

    After a brief moment, the application will be up and running.

  5. Create an S3 bucket manually

    The AWS S3 bucket is not created beforehand by the Compose file. Run the following command to create a new bucket within the LocalStack environment:

    $ awslocal s3 mb s3://mysamplebucket
    

    The command creates an S3 bucket named mysamplebucket.

    [!TIP]

    You can automate this step by placing a shell script (for example, init.sh) under the local ./localstack directory. Make sure the script is executable (chmod +x ./localstack/init.sh). LocalStack runs files mounted in /etc/localstack/init/ready.d once it is ready. See LocalStack init hooks for more details.

    Open http://localhost:5173 to access the complete to-do list application and start uploading images to the Amazon S3 bucket.

    [!TIP] To optimize performance and reduce upload times during development, consider uploading smaller image files. Larger images may take longer to process and could impact the overall responsiveness of the application.

Recap

This guide has walked you through setting up a local development environment using LocalStack and Docker. You’ve learned how to test AWS-based applications locally, reducing costs and increasing the efficiency of your development workflow.

Run Mistral Vibe in a Docker Sandbox

Mistral Vibe is Mistral's open source coding agent. This guide shows how to package it as a Docker Sandbox agent so it runs in an isolated microVM instead of directly on your host. The agent reaches Mistral through the sandbox proxy, so your API key stays on the host.

Install a pinned Vibe version when you build the kit, then reuse that image for each sandbox. The kit also declares the network access and credentials Vibe needs at runtime.

In this guide, you'll learn how to:

This guide uses v3 kits. Kits are Early Access, and sbx env is experimental.

How isolation works

Outbound TCP traffic from the sandbox passes through a proxy on your host. The proxy enforces network policy and injects credentials into matching HTTP requests. Vibe sees MISTRAL_API_KEY=proxy-managed inside the microVM. The real key remains in the host secret store.

Mistral is a built-in service. The kit declares the mistral credential and the three hosts where Vibe uses it: api.mistral.ai for inference, console.mistral.ai for account lookup, and chat.mistral.ai for managed config. Network access and credential injection are separate declarations. An allowed package download doesn't receive the Mistral authorization header.

Prerequisites

Before you start, make sure you have:

The sample run uses Vibe 2.25.8 and was verified with sbx 0.46.0-rc5.

Step 1: Store the Mistral key

Run this command on your host and paste the key when prompted:

$ sbx secret set mistral

Service secrets are global by default, so sandboxes that declare the mistral service can request this key. Use --sandbox to scope a secret to one sandbox. The key is stored separately from the kit and workspace. See Credentials.

Step 2: Install Vibe in the image

Create a kit directory beside the project you want Vibe to work on. Run the following command from the directory that will contain both:

$ mkdir mistral-vibe

If you don't have an existing project, create an empty workspace beside the kit to verify the agent launch:

$ mkdir my-project

The kit has two files with matching filename stems:

mistral-vibe/
├── vibe.yaml
└── vibe.dockerfile

Create mistral-vibe/vibe.dockerfile. The shell-docker template supplies the agent account, Python, uv, and the sandbox's Docker tooling:

FROM docker/sandbox-templates:shell-docker

ARG VIBE_VERSION

USER agent
RUN uv tool install "mistral-vibe==${VIBE_VERSION}" \
    && uv cache clean \
    && vibe --version

ENV PATH=/home/agent/.local/bin:${PATH} \
    VIBE_ENABLE_TELEMETRY=false \
    VIBE_ENABLE_AUTO_UPDATE=false \
    GIT_TERMINAL_PROMPT=0

WORKDIR /home/agent/workspace
ENTRYPOINT ["vibe", "--trust"]
CMD []

The descriptor in the next step supplies VIBE_VERSION. Installing as agent puts the executable in /home/agent/.local/bin. Turning off Vibe's automatic update keeps the installed agent version fixed until you rebuild.

--trust lets Vibe load the mounted project's configuration without a workspace trust prompt. Vibe still uses its tool permission prompts. Review the project configuration before mounting it.

Step 3: Declare access and credentials

Create mistral-vibe/vibe.yaml. The syntax directive selects the v3 kit frontend, which builds the companion Dockerfile and packages the descriptor with the image:

# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: workload
displayName: Mistral Vibe
description: Mistral Vibe with host-managed API credentials

args:
  version:
    default: "2.25.8"
    pattern: '^[0-9]+\.[0-9]+\.[0-9]+$'
    buildArg: VIBE_VERSION

provides: ["vibe@${{ kit.args.version }}"]

capabilities:
  - type: com.docker.sandbox/sbx@1

  - type: com.docker.sandbox/network-policy@1
    config:
      runtime:
        allow:
          - api.mistral.ai:443
          - console.mistral.ai:443
          - chat.mistral.ai:443

  - type: com.docker.sandbox/credential@1
    description: Mistral API access
    config:
      service: mistral
      phase: runtime
      apiKey:
        name: MISTRAL_API_KEY
        proxyManaged: true
        inject:
          - domain: api.mistral.ai
            header: Authorization
            format: "Bearer %s"
          - domain: console.mistral.ai
            header: Authorization
            format: "Bearer %s"
          - domain: chat.mistral.ai
            header: Authorization
            format: "Bearer %s"

kind: workload declares a kit that supplies the sandbox's root filesystem. args.version selects the Vibe release, maps it to the Dockerfile build argument, and records that version in provides. To install another release, change the default and rebuild.

The sbx@1 capability tells Docker Sandboxes to manage the agent's launch. The image supplies its user, working directory, and entrypoint. network-policy@1 permits HTTPS traffic to the three Mistral hosts. Package installation happens during the image build, so PyPI doesn't need a runtime rule unless your coding task downloads packages.

credential@1 gives Vibe a placeholder in MISTRAL_API_KEY and tells the host proxy to write the real key into the Authorization header. List each injection host explicitly in the network allowlist. The v3 validator checks these declarations against each other; a wildcard alone doesn't cover an injection host for validation.

Step 4: Build and publish the kit

From the directory containing mistral-vibe, validate and build the kit without publishing it:

$ docker buildx build ./mistral-vibe \
    --file ./mistral-vibe/vibe.yaml \
    --output type=cacheonly

The build validates the descriptor and installs Vibe. Use the descriptor with --file, rather than the companion Dockerfile, so the output includes the kit metadata and capabilities.

Optional. To share the kit, sign in to your registry, replace YOUR_NAMESPACE with your Docker Hub namespace, and publish for both common architectures:

$ docker login
$ docker buildx build ./mistral-vibe \
    --file ./mistral-vibe/vibe.yaml \
    --platform linux/amd64,linux/arm64 \
    --provenance=true \
    --sbom=true \
    --tag docker.io/YOUR_NAMESPACE/sbx-kit-vibe:2.25.8 \
    --push

This publishes the workload and its descriptor as one OCI image, with provenance and software bill of materials attestations. You can run the published reference in place of the local kit path in the next step.

Step 5: Run and verify Vibe

From the same directory, launch the local kit against your project. Replace ./my-project with the path to the workspace Vibe should edit:

$ sbx run --name mistral-vibe ./mistral-vibe ./my-project

--name gives the sandbox a predictable name for later commands. The kit path identifies the workload; the project path identifies the directory mounted read-write. Vibe's changes to that workspace are visible on your host.

When prompted, approve the kit's request to use your stored Mistral key. Storing the key and approving its use are separate steps. See Credential bindings.

At the Vibe prompt, prefix a shell command with ! to inspect the value available inside the sandbox:

!printf 'MISTRAL_API_KEY=%s\n' "$MISTRAL_API_KEY"

Expected output:

MISTRAL_API_KEY=proxy-managed

Vibe receives the placeholder while the proxy authenticates its Mistral requests. The following capture shows this check in the sample workspace used in the next section. Vibe displays the mounted workspace's path; your path and account greeting will differ from the screenshots:

Vibe 2.25.8 displaying MISTRAL_API_KEY=proxy-managed inside the sandbox

Try a coding task

The community-maintained Mistral Vibe sample contains a small FastAPI application, three tests, and one intentional bug. It uses the published Vibe kit, so you can try this exercise without building or publishing your own kit. This starts a separate sandbox from the sample's checked-in environment file. Your local kit sandbox remains available for later use.

Type /exit at the Vibe prompt to return to your host terminal. Then run these commands from a directory where you want to keep the sample:

$ git clone https://github.com/shelajev/mistral-vibe-sbx-demo.git
$ cd mistral-vibe-sbx-demo
$ sbx env run

sbx env run reads the repository's sbxenv.yaml, pulls docker.io/olegselajev241/sbx-kit-vibe:2.25.8, mounts only app/, and opens Vibe in mistral-vibe-demo-v3. Approve the environment plan and credential binding when prompted. Later runs attach to the same sandbox.

The environment file uses schema version 1; the kit uses schema version 3. These formats have independent version numbers. See Environment files.

Before starting the coding task, open a second host terminal and permit Python package downloads for this sandbox:

$ sbx policy allow network --sandbox mistral-vibe-demo-v3 \
    pypi.org,files.pythonhosted.org

The published kit permits Mistral traffic; the application needs these additional hosts if uv downloads its test dependencies. The rule is scoped to the sample sandbox, and package requests receive no Mistral credential.

Return to Vibe, which uses the project's mistral-medium-3.5 model, and paste this repair prompt:

Run the test suite and diagnose the failure. Fix the application code without changing
the tests or the public API contract. Run the full test suite again and summarize the
cause and your change.

Approve the shell commands and edits when Vibe asks. The failing test checks GET /tasks?completed=false: the endpoint returns all tasks instead of only incomplete tasks. A correct repair changes if completed: to if completed is not None:.

Vibe explaining the boolean-filter bug, its application-code repair, and that all three tests pass

Check the test suite yourself at the Vibe prompt:

!uv run pytest

The test suite should end with 3 passed. In your second host terminal, change to the mistral-vibe-sbx-demo repository and inspect the edit:

$ git diff -- app/main.py

The repository's .git directory stays on the host because only app/ is mounted. The diff should show the filter change. To repeat the exercise, type /exit in Vibe and run git restore app/main.py from the host repository to restore the intentional bug.

Optional. Run /model inside Vibe and select GLM 5.2 (Mistral hosted). The sample adds zai-glm-5-2 to app/.vibe/config.toml; the local kit you built earlier doesn't add this model configuration.

Vibe's model menu showing Mistral Medium 3.5 and GLM 5.2 hosted by Mistral

Troubleshoot and iterate

If Vibe can't reach a service, inspect the sandbox's proxy log from your host:

$ sbx policy log mistral-vibe

For the sample environment, use mistral-vibe-demo-v3 instead. If another coding task needs additional services, add the required hosts to the descriptor or allow them with sbx policy allow network --sandbox.

After editing your local descriptor or Dockerfile, return to the host directory containing mistral-vibe and create another sandbox to test it. Restarting an existing sandbox doesn't apply kit changes:

$ sbx run --name mistral-vibe-test-2 ./mistral-vibe ./my-project

If Vibe keeps retrying without a response, inspect its log from the host:

$ sbx exec mistral-vibe -- tail -n 50 /home/agent/.vibe/logs/vibe.log

For the sample, use mistral-vibe-demo-v3. Repeated HTTP 429 responses indicate a rate or allowance limit. Check the key's organization and workspace at Mistral limits. A response with X-Ratelimit-Limit-Req-Minute: 0 indicates zero request allowance; the verified sample required activating pay-as-you-go. For authentication failures, check sbx secret ls for a mistral entry and confirm the credential binding covers all three Mistral hosts.

Clean up

Sandboxes persist after Vibe exits. To stop the local sandbox:

$ sbx stop mistral-vibe

To remove it:

$ sbx rm mistral-vibe

If you created mistral-vibe-test-2, remove it with sbx rm mistral-vibe-test-2 as well. From the sample repository, remove its environment with:

$ sbx env rm --force

Removing a sandbox preserves the mounted workspace and its edits.

Learn more

Containerize a Next.js application

This guide shows you how to containerize a Next.js application using Docker, following best practices for creating efficient, production-ready containers.

Next.js is a React framework that enables server-side rendering, static site generation, and full-stack capabilities. Docker provides a consistent containerized environment from development to production.

Acknowledgment

Docker extends its sincere gratitude to Kristiyan Velkov for authoring this guide and contributing the official Next.js Docker examples to the Vercel Next.js repository, including the standalone and export output examples. As a Docker Captain and experienced engineer, his expertise in Docker, DevOps, and modern web development has made this resource invaluable for the community, helping developers navigate and optimize their Docker workflows.


What will you learn?

In this guide, you will learn how to:

To begin, you'll start by containerizing an existing Next.js application.


Prerequisites

Before you begin, make sure you're familiar with the following:

Once you've completed the Next.js getting started modules, you'll be ready to containerize your own Next.js application using the examples and instructions provided in this guide.

Containerize a Next.js Application

Prerequisites

Before you begin, make sure the following tools are installed and available on your system:

[!NOTE] New to Docker? Start with the Docker basics guide to get familiar with key concepts like images, containers, and Dockerfiles.


Overview

This guide walks you through containerizing a Next.js application with Docker. You'll learn how to create a production-ready Docker image using best practices that improve performance, security, scalability, and deployment efficiency.

By the end of this guide, you will:


Get the sample application

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the git repository:

$ git clone https://github.com/kristiyan-velkov/docker-nextjs-sample

Build the Docker image

Next.js has specific requirements for production deployments. This guide shows two approaches: standalone output (Node.js server) and export output (static files with Nginx).

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

Step 1: Configure Next.js and create the Dockerfile

Before creating a Dockerfile, choose a base image: the Node.js Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog. Choosing DHI gives you a production-ready, lightweight, and secure image. For more information, see Docker Hardened Images.

[!IMPORTANT] This guide uses stable Node.js LTS image tags that are considered secure when the guide is written. Because new releases and security patches are published regularly, always review the official Node.js Docker images and select a secure, up-to-date version before building or deploying.


1.1 Next.js with standalone output

Standalone output (output: "standalone") makes Next.js build a self-contained output that includes only the files and dependencies needed to run the application. A single node server.js can serve the app, which is ideal for Docker and supports server-side rendering, API routes, and incremental static regeneration. For details, see the Next.js output configuration documentation (including the "standalone" option).

The container runs the Next.js server with Node.js on port 3000.

Configure Next.js — Open or create next.config.ts in your project root:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

Choose either a Docker Hardened Image or the Docker Official Image, then create a Dockerfile using the content from the selected tab below.

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

Docker Hardened Images (DHIs) are available for Node.js in the Docker Hardened Images catalog. For more information, see the DHI quickstart guide.

  1. Sign in to the DHI registry:

    $ docker login dhi.io
    
  2. Pull the Node.js DHI (check the catalog for available versions):

    $ docker pull dhi.io/node:24-alpine3.22-dev
    
  3. Create a file named Dockerfile with the following contents. The FROM instructions use dhi.io/node:24-alpine3.22-dev. Check the Docker Hardened Images catalog for the latest versions and update the image tags as needed for security and compatibility.

    # ============================================
    # Stage 1: Dependencies Installation Stage
    # ============================================
    
    # IMPORTANT: Docker Hardened Image (DHI) Version Maintenance
    # This Dockerfile uses dhi.io/node. Regularly validate and update to the latest DHI versions in the catalog for security and compatibility.
    
    FROM dhi.io/node:24-alpine3.22-dev AS dependencies
    
    # Set working directory
    WORKDIR /app
    
    # Copy package-related files first to leverage Docker's caching mechanism
    COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
    
    # Install project dependencies with frozen lockfile for reproducible builds
    RUN --mount=type=cache,target=/root/.npm \
        --mount=type=cache,target=/usr/local/share/.cache/yarn \
        --mount=type=cache,target=/root/.local/share/pnpm/store \
      if [ -f package-lock.json ]; then \
        npm ci --no-audit --no-fund; \
      elif [ -f yarn.lock ]; then \
        corepack enable yarn && yarn install --frozen-lockfile --production=false; \
      elif [ -f pnpm-lock.yaml ]; then \
        corepack enable pnpm && pnpm install --frozen-lockfile; \
      else \
        echo "No lockfile found." && exit 1; \
      fi
    
    # ============================================
    # Stage 2: Build Next.js application in standalone mode
    # ============================================
    
    FROM dhi.io/node:24-alpine3.22-dev AS builder
    
    # Set working directory
    WORKDIR /app
    
    # Copy project dependencies from dependencies stage
    COPY --from=dependencies /app/node_modules ./node_modules
    
    # Copy application source code
    COPY . .
    
    ENV NODE_ENV=production
    
    # Next.js collects completely anonymous telemetry data about general usage.
    # Learn more here: https://nextjs.org/telemetry
    # Uncomment the following line in case you want to disable telemetry during the build.
    # ENV NEXT_TELEMETRY_DISABLED=1
    
    # Build Next.js application
    # If you want to speed up Docker rebuilds, you can cache the build artifacts
    # by adding: --mount=type=cache,target=/app/.next/cache
    # This caches the .next/cache directory across builds, but it also prevents
    # .next/cache/fetch-cache from being included in the final image, meaning
    # cached fetch responses from the build won't be available at runtime.
    RUN if [ -f package-lock.json ]; then \
        npm run build; \
      elif [ -f yarn.lock ]; then \
        corepack enable yarn && yarn build; \
      elif [ -f pnpm-lock.yaml ]; then \
        corepack enable pnpm && pnpm build; \
      else \
        echo "No lockfile found." && exit 1; \
      fi
    
    # ============================================
    # Stage 3: Run Next.js application
    # ============================================
    
    FROM dhi.io/node:24-alpine3.22-dev AS runner
    
    # Set working directory
    WORKDIR /app
    
    # Set production environment variables
    ENV NODE_ENV=production
    ENV PORT=3000
    ENV HOSTNAME="0.0.0.0"
    
    # Next.js collects completely anonymous telemetry data about general usage.
    # Learn more here: https://nextjs.org/telemetry
    # Uncomment the following line in case you want to disable telemetry during the run time.
    # ENV NEXT_TELEMETRY_DISABLED=1
    
    # Copy production assets
    COPY --from=builder --chown=node:node /app/public ./public
    
    # Set the correct permission for prerender cache
    RUN mkdir .next
    RUN chown node:node .next
    
    # Automatically leverage output traces to reduce image size
    # https://nextjs.org/docs/advanced-features/output-file-tracing
    COPY --from=builder --chown=node:node /app/.next/standalone ./
    COPY --from=builder --chown=node:node /app/.next/static ./.next/static
    
    # If you want to persist the fetch cache generated during the build so that
    # cached responses are available immediately on startup, uncomment this line:
    # COPY --from=builder --chown=node:node /app/.next/cache ./.next/cache
    
    # Switch to non-root user for security best practices
    USER node
    
    # Expose port 3000 to allow HTTP traffic
    EXPOSE 3000
    
    # Start Next.js standalone server
    CMD ["node", "server.js"]
    

{{< /tab >}} {{< tab name="Using the Docker Official Image" >}}

Create a file named Dockerfile with the following contents (uses node):

  # ============================================
  # Stage 1: Dependencies Installation Stage
  # ============================================

  ARG NODE_VERSION=24.14.0-slim

  FROM node:${NODE_VERSION} AS dependencies

  # Set working directory
  WORKDIR /app

  # Copy package-related files first to leverage Docker's caching mechanism
  COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./

  # Install project dependencies with frozen lockfile for reproducible builds
  RUN --mount=type=cache,target=/root/.npm \
      --mount=type=cache,target=/usr/local/share/.cache/yarn \
      --mount=type=cache,target=/root/.local/share/pnpm/store \
    if [ -f package-lock.json ]; then \
      npm ci --no-audit --no-fund; \
    elif [ -f yarn.lock ]; then \
      corepack enable yarn && yarn install --frozen-lockfile --production=false; \
    elif [ -f pnpm-lock.yaml ]; then \
      corepack enable pnpm && pnpm install --frozen-lockfile; \
    else \
      echo "No lockfile found." && exit 1; \
    fi

  # ============================================
  # Stage 2: Build Next.js application in standalone mode
  # ============================================

  FROM node:${NODE_VERSION} AS builder

  # Set working directory
  WORKDIR /app

  # Copy project dependencies from dependencies stage
  COPY --from=dependencies /app/node_modules ./node_modules

  # Copy application source code
  COPY . .

  ENV NODE_ENV=production

  # Next.js collects completely anonymous telemetry data about general usage.
  # Learn more here: https://nextjs.org/telemetry
  # Uncomment the following line in case you want to disable telemetry during the build.
  # ENV NEXT_TELEMETRY_DISABLED=1

  # Build Next.js application
  # If you want to speed up Docker rebuilds, you can cache the build artifacts
  # by adding: --mount=type=cache,target=/app/.next/cache
  # This caches the .next/cache directory across builds, but it also prevents
  # .next/cache/fetch-cache from being included in the final image, meaning
  # cached fetch responses from the build won't be available at runtime.
  RUN if [ -f package-lock.json ]; then \
      npm run build; \
    elif [ -f yarn.lock ]; then \
      corepack enable yarn && yarn build; \
    elif [ -f pnpm-lock.yaml ]; then \
      corepack enable pnpm && pnpm build; \
    else \
      echo "No lockfile found." && exit 1; \
    fi

  # ============================================
  # Stage 3: Run Next.js application
  # ============================================

  FROM node:${NODE_VERSION} AS runner

  # Set working directory
  WORKDIR /app

  # Set production environment variables
  ENV NODE_ENV=production
  ENV PORT=3000
  ENV HOSTNAME="0.0.0.0"

  # Next.js collects completely anonymous telemetry data about general usage.
  # Learn more here: https://nextjs.org/telemetry
  # Uncomment the following line in case you want to disable telemetry during the run time.
  # ENV NEXT_TELEMETRY_DISABLED=1

  # Copy production assets
  COPY --from=builder --chown=node:node /app/public ./public

  # Set the correct permission for prerender cache
  RUN mkdir .next
  RUN chown node:node .next

  # Automatically leverage output traces to reduce image size
  # https://nextjs.org/docs/advanced-features/output-file-tracing
  COPY --from=builder --chown=node:node /app/.next/standalone ./
  COPY --from=builder --chown=node:node /app/.next/static ./.next/static

  # If you want to persist the fetch cache generated during the build so that
  # cached responses are available immediately on startup, uncomment this line:
  # COPY --from=builder --chown=node:node /app/.next/cache ./.next/cache

  # Switch to non-root user for security best practices
  USER node

  # Expose port 3000 to allow HTTP traffic
  EXPOSE 3000

  # Start Next.js standalone server
  CMD ["node", "server.js"]

[!NOTE] This Dockerfile uses three stages: dependencies, builder, and runner. The final image runs node server.js and listens on port 3000.

{{< /tab >}} {{< /tabs >}}


1.2 Next.js with export output

Output export (output: "export") makes Next.js build a fully static site at build time. It generates HTML, CSS, and JavaScript into an out directory that can be served by any static host or CDN—no Node.js server at runtime. Use this when you don't need server-side rendering or API routes. For details, see the Next.js output configuration documentation.

Configure Next.js — Open next.config.ts in your project root and add the following code:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: {
    unoptimized: true,
  },
};

export default nextConfig;

Choose either a Docker Hardened Image or the Docker Official Image, then create a Dockerfile using the content from the selected tab below.

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

Docker Hardened Images (DHIs) are available for Node.js and Nginx in the Docker Hardened Images catalog. For more information, see the DHI quickstart guide.

  1. Sign in to the DHI registry:

    $ docker login dhi.io
    
  2. Pull the Node.js DHI (check the catalog for available versions):

    $ docker pull dhi.io/node:24-alpine3.22-dev
    
  3. Pull the Nginx DHI (check the catalog for available versions):

    $ docker pull dhi.io/nginx:1.28.0-alpine3.21-dev
    
  4. Create a file named Dockerfile with the following contents. The FROM instructions use Docker Hardened Images: dhi.io/node:24-alpine3.22-dev and dhi.io/nginx:1.28.0-alpine3.21-dev. Check the Docker Hardened Images catalog for the latest versions and update the image tags as needed for security and compatibility.

    # ============================================
    # Stage 1: Dependencies Installation Stage
    # ============================================
    
    # IMPORTANT: Docker Hardened Image (DHI) Version Maintenance
    # This Dockerfile uses dhi.io/node and dhi.io/nginx. Regularly validate and update to the latest DHI versions in the catalog for security and compatibility.
    
    FROM dhi.io/node:24-alpine3.22-dev AS dependencies
    
    # Set the working directory
    WORKDIR /app
    
    # Copy package-related files first to leverage Docker's caching mechanism
    COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
    
    # Install project dependencies with frozen lockfile for reproducible builds
    RUN --mount=type=cache,target=/root/.npm \
        --mount=type=cache,target=/usr/local/share/.cache/yarn \
        --mount=type=cache,target=/root/.local/share/pnpm/store \
      if [ -f package-lock.json ]; then \
        npm ci --no-audit --no-fund; \
      elif [ -f yarn.lock ]; then \
        corepack enable yarn && yarn install --frozen-lockfile --production=false; \
      elif [ -f pnpm-lock.yaml ]; then \
        corepack enable pnpm && pnpm install --frozen-lockfile; \
      else \
        echo "No lockfile found." && exit 1; \
      fi
    
    # ============================================
    # Stage 2: Build Next.js Application
    # ============================================
    
    FROM dhi.io/node:24-alpine3.22-dev AS builder
    
    # Set the working directory
    WORKDIR /app
    
    # Copy project dependencies from dependencies stage
    COPY --from=dependencies /app/node_modules ./node_modules
    
    # Copy application source code
    COPY . .
    
    ENV NODE_ENV=production
    
    # Next.js collects completely anonymous telemetry data about general usage.
    # Learn more here: https://nextjs.org/telemetry
    # Uncomment the following line in case you want to disable telemetry during the build.
    # ENV NEXT_TELEMETRY_DISABLED=1
    
    # Build Next.js application
    RUN --mount=type=cache,target=/app/.next/cache \
      if [ -f package-lock.json ]; then \
        npm run build; \
      elif [ -f yarn.lock ]; then \
        corepack enable yarn && yarn build; \
      elif [ -f pnpm-lock.yaml ]; then \
        corepack enable pnpm && pnpm build; \
      else \
        echo "No lockfile found." && exit 1; \
      fi
    
    # =========================================
    # Stage 3: Serve Static Files with Nginx
    # =========================================
    
    FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
    
    # Set the working directory
    WORKDIR /app
    
    # Next.js collects completely anonymous telemetry data about general usage.
    # Learn more here: https://nextjs.org/telemetry
    # Uncomment the following line in case you want to disable telemetry during the run time.
    # ENV NEXT_TELEMETRY_DISABLED=1
    
    # Copy custom Nginx config
    COPY nginx.conf /etc/nginx/nginx.conf
    
    # Copy the static build output from the build stage to Nginx's default HTML serving directory
    COPY --chown=nginx:nginx --from=builder /app/out /usr/share/nginx/html
    
    # Non-root user for security best practices
    USER nginx
    
    # Expose port 8080 to allow HTTP traffic
    EXPOSE 8080
    
    # Start Nginx directly with custom config
    ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
    CMD ["-g", "daemon off;"]
    

{{< /tab >}} {{< tab name="Using the Docker Official Image" >}}

Create a file named Dockerfile with the following contents (uses node and nginxinc/nginx-unprivileged):

# ============================================
# Stage 1: Dependencies Installation Stage
# ============================================

ARG NODE_VERSION=24.14.0-slim
ARG NGINXINC_IMAGE_TAG=alpine3.22

FROM node:${NODE_VERSION} AS dependencies

# Set the working directory
WORKDIR /app

# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./

# Install project dependencies with frozen lockfile for reproducible builds
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=cache,target=/usr/local/share/.cache/yarn \
    --mount=type=cache,target=/root/.local/share/pnpm/store \
  if [ -f package-lock.json ]; then \
    npm ci --no-audit --no-fund; \
  elif [ -f yarn.lock ]; then \
    corepack enable yarn && yarn install --frozen-lockfile --production=false; \
  elif [ -f pnpm-lock.yaml ]; then \
    corepack enable pnpm && pnpm install --frozen-lockfile; \
  else \
    echo "No lockfile found." && exit 1; \
  fi

# ============================================
# Stage 2: Build Next.js Application
# ============================================

FROM node:${NODE_VERSION} AS builder

# Set the working directory
WORKDIR /app

# Copy project dependencies from dependencies stage
COPY --from=dependencies /app/node_modules ./node_modules

# Copy application source code
COPY . .

ENV NODE_ENV=production

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1

# Build Next.js application
RUN --mount=type=cache,target=/app/.next/cache \
  if [ -f package-lock.json ]; then \
    npm run build; \
  elif [ -f yarn.lock ]; then \
    corepack enable yarn && yarn build; \
  elif [ -f pnpm-lock.yaml ]; then \
    corepack enable pnpm && pnpm build; \
  else \
    echo "No lockfile found." && exit 1; \
  fi

# =========================================
# Stage 3: Serve Static Files with Nginx
# =========================================

FROM nginxinc/nginx-unprivileged:${NGINXINC_IMAGE_TAG} AS runner

# Set the working directory
WORKDIR /app

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the run time.
# ENV NEXT_TELEMETRY_DISABLED=1

# Copy custom Nginx config
COPY nginx.conf /etc/nginx/nginx.conf

# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --from=builder /app/out /usr/share/nginx/html

# Non-root user for security best practices
USER nginx

# Expose port 8080 to allow HTTP traffic
EXPOSE 8080

# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]

[!NOTE] This guide uses nginx-unprivileged instead of the standard Nginx image to run as a non-root user, following security best practices.

{{< /tab >}} {{< /tabs >}}

  1. Create nginx.conf (required for export output only) — Create a file named nginx.conf in the root of your project:

    # Minimal Nginx config for static Next.js app
    worker_processes 1;
    
    # Store PID in /tmp (always writable)
    pid /tmp/nginx.pid;
    
    events {
        worker_connections 1024;
    }
    
    http {
        include       /etc/nginx/mime.types;
        default_type  application/octet-stream;
    
        # Disable logging to avoid permission issues
        access_log off;
        error_log  /dev/stderr;
    
        # Optimize static file serving
        sendfile        on;
        tcp_nopush      on;
        tcp_nodelay     on;
        keepalive_timeout  65;
    
        # Gzip compression
        gzip on;
        gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
        gzip_min_length 256;
    
        server {
            listen       8080;
            server_name  localhost;
    
            # Serve static files
            root /usr/share/nginx/html;
            index index.html;
    
            # Handle Next.js static export routing
            # See: https://nextjs.org/docs/app/guides/static-exports#deploying
            location / {
                try_files $uri $uri.html $uri/ =404;
            }
    
            # This is necessary when `trailingSlash: false` (default).
            # You can omit this when `trailingSlash: true` in next.config.
            # Handles nested routes like /blog/post -> /blog/post.html
            location ~ ^/(.+)/$ {
                rewrite ^/(.+)/$ /$1.html break;
            }
    
            # Serve Next.js static assets
            location ~ ^/_next/ {
                try_files $uri =404;
                expires 1y;
                add_header Cache-Control "public, immutable";
            }
    
            # Optional 404 handling
            error_page 404 /404.html;
            location = /404.html {
                internal;
            }
        }
    }
    

    [!NOTE] Export uses port 8080. For more details, see the Next.js output configuration and Nginx documentation.

Step 2: Create the compose.yaml file

Create a file named compose.yaml with the following contents:

services:
  server:
    build:
      context: .
    ports:
      - 3000:3000

[!NOTE] If using export output (Nginx), change the port mapping to 8080:8080.

Step 3: Create the .dockerignore file

The .dockerignore file tells Docker which files and folders to exclude when building the image.

[!NOTE] This helps:

To learn more, visit the .dockerignore reference.

Create a file named .dockerignore with the following contents:

# Dependencies (installed inside the image, never copy from host)
node_modules/
.pnp/
.pnp.js
.pnpm-store/

# Next.js build output (generated during the image build)
.next/
out/
dist/
build/
.vercel/

# Testing (not needed in the production image)
coverage/
.nyc_output/
__tests__/
__mocks__/
jest/
cypress/
playwright-report/
test-results/
.vitest/

# Environment files (avoid leaking secrets into the build context)
.env
.env*
.env.local
.env.development.local
.env.test.local
.env.production.local

# Debug and log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
*.log

# IDE and editor files
.vscode/
.idea/
.cursor/
.cursorrules
.copilot/
*.swp
*.swo
*~

# Git
.git/
.gitignore
.gitattributes

# Docker files (reduce build context; not needed inside the image)
Dockerfile*
.dockerignore
docker-compose*.yml
compose*.yaml

# Documentation (not needed in the image)
*.md
docs/

# CI/CD (not needed in the image)
.github/
.gitlab-ci.yml
.travis.yml
.circleci/
Jenkinsfile

# TypeScript and build metadata
*.tsbuildinfo

# Cache and temporary directories
.cache/
.parcel-cache/
.eslintcache
.stylelintcache
.turbo/
.tmp/
.temp/

# Sensitive or dev-only config (optional; omit if your build needs these)
.pem
.editorconfig
.prettierrc*
.eslintrc*
.stylelintrc*
.babelrc*
*.iml

# OS-specific files
.DS_Store
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini

Step 4: Build the Next.js application image

With your custom configuration in place, you're now ready to build the Docker image. Use the Dockerfile you created in Step 1 (standalone or export).

The setup includes:

After completing the previous steps, your project directory should contain at least the following files (export also requires nginx.conf):

├── docker-nextjs-sample/
│ ├── Dockerfile
│ ├── .dockerignore
│ ├── compose.yaml
│ └── next.config.ts

Now that your Dockerfile is configured, you can build the Docker image for your Next.js application.

[!NOTE] The docker build command packages your application into an image using the instructions in the Dockerfile. It includes all necessary files from the current directory (called the build context).

Run the following command from the root of your project:

$ docker build --tag nextjs-sample .

What this command does:

Step 5: View local images

After building your Docker image, you can check which images are available on your local machine using either the Docker CLI or Docker Desktop. Since you're already working in the terminal, let's use the Docker CLI.

To list all locally available Docker images, run the following command:

$ docker images

Example Output:

REPOSITORY                TAG               IMAGE ID       CREATED         SIZE
nextjs-sample             latest            8c5fc80f098e   14 seconds ago   130MB

This output provides key details about your images:

If the build was successful, you should see nextjs-sample image listed.


Run the containerized application

In the previous step, you created a Dockerfile for your Next.js application and built a Docker image using the docker build command. Now it's time to run that image in a container and verify that your application works as expected.

Run the following command in a terminal. Use the port that matches your setup: standalone uses port 3000, export uses port 8080.

$ docker run -p 3000:3000 nextjs-sample

For export output, use port 8080 instead:

$ docker run -p 8080:8080 nextjs-sample

Open a browser and view the application: http://localhost:3000 for standalone or http://localhost:8080 for export. You should see your Next.js web application.

Press ctrl+c in the terminal to stop your application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option and --name to give the container a name so you can stop it later:

$ docker run -d -p 3000:3000 --name nextjs-app nextjs-sample

For export output, use port 8080:

$ docker run -d -p 8080:8080 --name nextjs-app nextjs-sample

Open a browser and view the application: http://localhost:3000 for standalone or http://localhost:8080 for export. You should see your web application.

To confirm that the container is running, use the docker ps command:

$ docker ps

This will list all active containers along with their ports, names, and status. Look for a container exposing port 3000 (standalone) or 8080 (export).

Example Output:

CONTAINER ID   IMAGE           COMMAND                  CREATED             STATUS             PORTS                    NAMES
f49b74736a9d   nextjs-sample   "node server.js"         About a minute ago   Up About a minute   0.0.0.0:3000->3000/tcp nextjs-app

To stop the application, run:

$ docker stop nextjs-app

[!NOTE] For more information about running containers, see the docker run CLI reference and the docker stop CLI reference.


Use containers for Next.js development

Prerequisites

Complete Containerize Next.js application.


Overview

In this section, you'll learn how to set up both production and development environments for your containerized Next.js application using Docker Compose. This setup allows you to run a production build using the standalone server and to develop efficiently inside containers using Next.js's built-in hot reloading with Compose Watch.

You'll learn how to:


Automatically update services (development mode)

Use Compose Watch to automatically sync source file changes into your containerized development environment. This automatically syncs file changes without needing to restart or rebuild containers manually.

Step 1: Create a development Dockerfile

Create a file named Dockerfile.dev in your project root with the following content (matching the sample project):

# ============================================
# Development Dockerfile for Next.js
# ============================================
ARG NODE_VERSION=24.14.0-slim

FROM node:${NODE_VERSION} AS dev

WORKDIR /app

COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./

RUN --mount=type=cache,target=/root/.npm \
    --mount=type=cache,target=/usr/local/share/.cache/yarn \
    --mount=type=cache,target=/root/.local/share/pnpm/store \
  if [ -f package-lock.json ]; then \
    npm ci --no-audit --no-fund; \
  elif [ -f yarn.lock ]; then \
    corepack enable yarn && yarn install --frozen-lockfile --production=false; \
  elif [ -f pnpm-lock.yaml ]; then \
    corepack enable pnpm && pnpm install --frozen-lockfile; \
  else \
    echo "No lockfile found." && exit 1; \
  fi

COPY . .

ENV WATCHPACK_POLLING=true
ENV HOSTNAME="0.0.0.0"

RUN chown -R node:node /app
USER node

EXPOSE 3000

CMD ["sh", "-c", "if [ -f package-lock.json ]; then npm run dev; elif [ -f yarn.lock ]; then yarn dev; elif [ -f pnpm-lock.yaml ]; then pnpm dev; else npm run dev; fi"]

This file sets up a development environment for your Next.js app with hot module replacement and supports npm, yarn, and pnpm.

Step 2: Update your compose.yaml file

Open your compose.yaml file and define two services: one for production (nextjs-prod-standalone) and one for development (nextjs-dev). This matches the sample project structure.

Here's an example configuration for a Next.js application:

services:
  nextjs-prod-standalone:
    build:
      context: .
      dockerfile: Dockerfile
    image: nextjs-sample:prod
    container_name: nextjs-sample-prod
    ports:
      - "3000:3000"

  nextjs-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: nextjs-sample:dev
    container_name: nextjs-sample-dev
    ports:
      - "3000:3000"
    environment:
      - WATCHPACK_POLLING=true
    develop:
      watch:
        - action: sync
          path: .
          target: /app
          ignore:
            - node_modules/
            - .next/
        - action: rebuild
          path: package.json

[!NOTE] For more details, see the official guide: Use Compose Watch.

Step 3: Configure Next.js for Docker development

Next.js works well inside Docker containers out of the box, but there are a few configurations that can improve the development experience.

The next.config.ts file you created during containerization already includes the output: "standalone" option for production. For development, Next.js automatically uses its built-in development server with hot reloading enabled.

[!NOTE] The Next.js development server automatically:

The WATCHPACK_POLLING=true environment variable in the compose file ensures file watching works correctly inside Docker containers.

After completing the previous steps, your project directory should now contain the following files:

├── docker-nextjs-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ └── next.config.ts

Step 4: Start Compose Watch

Run the following command from your project root to start your container in watch mode:

$ docker compose watch nextjs-dev

Step 5: Test Compose Watch with Next.js

To verify that Compose Watch is working correctly:

  1. Open the app/page.tsx file in your text editor (or src/app/page.tsx if your project uses a src directory).

  2. Locate the main content area and find a text element to modify.

  3. Make a visible change, for example, update a heading:

    <h1>Hello from Docker Compose Watch!</h1>
    
  4. Save the file.

  5. Open your browser at http://localhost:3000.

You should see the updated text appear instantly, without needing to rebuild the container manually. This confirms that file watching and automatic synchronization are working as expected.


Run Next.js tests in a container

Prerequisites

Complete all the previous sections of this guide, starting with Containerize Next.js application.

Overview

Testing is a critical part of the development process. In this section, you'll learn how to:

The sample project uses Vitest with Testing Library for component testing. You can use the same setup or follow the alternative Jest configuration later.


Run tests during development

The sample project already includes lint (ESLint) and sample tests (Vitest, app/page.test.tsx) in place. If you're using the sample app, you can skip to Step 3: Update compose.yaml and run tests or lint with the commands below. If you're using your own project, follow the install and configuration steps to add the packages and scripts.

The sample includes a test file at:

app/page.test.tsx

This file uses Vitest and React Testing Library to verify the behavior of page components.

Step 1: Install Vitest and React Testing Library (custom projects)

If you're using a custom project and haven't already added the necessary testing tools, install them by running:

$ npm install --save-dev vitest @vitejs/plugin-react @testing-library/react @testing-library/dom jsdom

Then, update the scripts section of your package.json file to include:

"scripts": {
  "test": "vitest",
  "test:run": "vitest run"
}

For lint, add a lint script (and optionally lint:fix). For example, with ESLint:

"scripts": {
  "test": "vitest",
  "test:run": "vitest run",
  "lint": "eslint .",
  "lint:fix": "eslint . --fix"
}

The sample project uses eslint and eslint-config-next for Next.js. Install them in a custom project with:

$ npm install --save-dev eslint eslint-config-next @eslint/eslintrc

Create an ESLint config file (e.g. eslint.config.cjs) in your project root with Next.js rules and global ignores:

const { defineConfig, globalIgnores } = require("eslint/config");
const { FlatCompat } = require("@eslint/eslintrc");

const compat = new FlatCompat({ baseDirectory: __dirname });

module.exports = defineConfig([
  ...compat.extends(
    "eslint-config-next/core-web-vitals",
    "eslint-config-next/typescript",
  ),
  globalIgnores([
    ".next/**",
    "out/**",
    "build/**",
    "next-env.d.ts",
    "node_modules/**",
    "eslint.config.cjs",
  ]),
]);

Step 2: Configure Vitest (custom projects)

If you're using a custom project, create a vitest.config.ts file in your project root (matching the sample project):

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: "./vitest.setup.ts",
    globals: true,
  },
});

Create a vitest.setup.ts file in your project root:

import "@testing-library/jest-dom/vitest";

[!NOTE] Vitest works well with Next.js and provides fast execution and ESM support. For more details, see the Next.js testing documentation and Vitest docs.

Step 3: Update compose.yaml

Add nextjs-test and nextjs-lint services to your compose.yaml file. In the sample project these services use the tools profile so they don't start with a normal docker compose up. Both reuse Dockerfile.dev and run the test or lint command:

services:
  nextjs-prod-standalone:
    build:
      context: .
      dockerfile: Dockerfile
    image: nextjs-sample:prod
    container_name: nextjs-sample-prod
    ports:
      - "3000:3000"

  nextjs-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: nextjs-sample:dev
    container_name: nextjs-sample-dev
    ports:
      - "3000:3000"
    environment:
      - WATCHPACK_POLLING=true
    develop:
      watch:
        - action: sync
          path: .
          target: /app
          ignore:
            - node_modules/
            - .next/
        - action: rebuild
          path: package.json

  nextjs-test:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: nextjs-sample:dev
    container_name: nextjs-sample-test
    command:
      [
        "sh",
        "-c",
        "if [ -f package-lock.json ]; then npm run test:run 2>/dev/null || npm run test -- --run; elif [ -f yarn.lock ]; then yarn test:run 2>/dev/null || yarn test --run; elif [ -f pnpm-lock.yaml ]; then pnpm run test:run; else npm run test -- --run; fi",
      ]
    profiles:
      - tools

  nextjs-lint:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: nextjs-sample:dev
    container_name: nextjs-sample-lint
    command:
      [
        "sh",
        "-c",
        "if [ -f package-lock.json ]; then npm run lint; elif [ -f yarn.lock ]; then yarn lint; elif [ -f pnpm-lock.yaml ]; then pnpm lint; else npm run lint; fi",
      ]
    profiles:
      - tools

The nextjs-test and nextjs-lint services reuse the same Dockerfile.dev used for development and override the default command to run tests or lint. The profiles: [tools] means these services only run when you use the --profile tools option.

After completing the previous steps, your project directory should contain:

├── docker-nextjs-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ ├── vitest.config.ts
│ ├── vitest.setup.ts
│ └── next.config.ts

Step 4: Run the tests

To execute your test suite inside the container, run from your project root:

$ docker compose --profile tools run --rm nextjs-test

This command will:

[!NOTE] For more information about Compose commands and profiles, see the Compose CLI reference.

Step 5: Run lint in the container

To run your linter (e.g. ESLint) inside the container, use the nextjs-lint service with the same tools profile:

$ docker compose --profile tools run --rm nextjs-lint

This command will:

Ensure your package.json includes a lint script. The sample project already has "lint": "eslint ." and "lint:fix": "eslint . --fix"; for a custom project, add the same and install eslint and eslint-config-next if needed.


Summary

In this section, you learned how to run unit tests for your Next.js application inside a Docker container using Vitest and Docker Compose.

What you accomplished:


Explore official references and best practices to sharpen your Docker testing workflow:

Node.js language-specific guide

Node.js is a JavaScript runtime for building server-side applications. This guide shows you how to containerize a TypeScript Node.js application using Docker, starting from a simple Express API and progressively adding features like a database.

This guide focuses on a backend Node.js API. If you're building a standalone frontend application, Docker has dedicated guides for React.js, Vue.js, Angular, and Next.js.

Acknowledgment

Docker thanks Kristiyan Velkov for his contribution to this guide.

What will you learn?

In this guide, you'll learn how to:

Start by containerizing a Node.js application.

Prerequisites

Containerize a Node.js application

Prerequisites

Overview

Containerizing your application means packaging it together with its dependencies, configuration, and runtime into a single portable unit called a container image. Running that image creates a container, an isolated process that behaves the same on any machine, whether it's your laptop, a CI runner, or a production server.

In this section, you'll containerize a simple Express.js API written in TypeScript. You'll write a Dockerfile that describes how to build the image, add a compose.yaml file that defines how Docker runs your container, and then build and start the application with one command.

You'll use Docker Hardened Images as the base. These are minimal, secure Node.js images maintained by Docker.

This guide focuses on a backend Node.js API. If you're building a standalone frontend application, Docker has dedicated guides for React.js, Vue.js, Angular, and Next.js.

Create the application

The sample application is a minimal Express API with a single endpoint that returns a JSON greeting. Create the following files in a new nodejs-docker-example directory. To create all the files at once, switch to the Scaffold script tab in the file browser and copy the shell command.

{{< files name="nodejs-docker-example" >}}

{{< file path="src/index.ts" status="new" >}}

// A minimal Express application.
// The root endpoint (GET /) returns a JSON greeting.
// See https://expressjs.com/ for the framework reference.

import express, { type Request, type Response } from "express";

const app = express();
const port = parseInt(process.env.PORT ?? "3000", 10);

app.get("/", (_req: Request, res: Response) => {
  res.json({ message: "Hello World" });
});

app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});

{{< /file >}}

{{< file path="package.json" status="new" >}}

{
  "name": "nodejs-docker-example",
  "version": "1.0.0",
  "description": "A minimal Node.js TypeScript application.",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts"
  },
  "dependencies": {
    "express": "^4.21.2"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^22.0.0",
    "tsx": "^4.19.3",
    "typescript": "^5.8.3"
  }
}

{{< /file >}}

{{< file path="tsconfig.json" status="new" >}}

{
  // TypeScript compiler configuration for the Node.js application.
  // Compiles src/ to dist/ as CommonJS modules targeting ES2022.
  // See https://www.typescriptlang.org/tsconfig/ for all options.
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

{{< /file >}}

{{< file path=".gitignore" status="new" >}}

# Files and directories that Git should ignore. Covers Node.js dependencies,
# TypeScript build output, environment files, and common editor artifacts.
# See https://git-scm.com/docs/gitignore for syntax reference.

node_modules/
dist/
.env
*.log
.DS_Store
coverage/
db/password.txt

{{< /file >}}

{{< /files >}}

If you have Node.js installed and want to verify the app works before containerizing it, you can run it locally.

To run in development mode with hot-reload:

$ npm install
$ npm run dev

To run the compiled production build (matching what the Dockerfile does):

$ npm install
$ npm run build
$ npm start

Then open http://localhost:3000 in your browser. You should see {"message":"Hello World"}.

If you don't have Node.js installed, skip ahead. The remaining steps run the application in a container, with no local Node.js required.

Create the Docker assets

Sign in to the DHI registry so Docker can pull the Node.js base images during the build. The available Node.js images are listed in the catalog.

$ docker login dhi.io

Add the following three files to your nodejs-docker-example directory. The Dockerfile describes how to build the image, compose.yaml defines how Docker runs the container, and .dockerignore keeps unwanted files out of the build context.

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

{{< files name="nodejs-docker-example" >}}

{{< file path="src/index.ts" >}}

// A minimal Express application.
// The root endpoint (GET /) returns a JSON greeting.
// See https://expressjs.com/ for the framework reference.

import express, { type Request, type Response } from "express";

const app = express();
const port = parseInt(process.env.PORT ?? "3000", 10);

app.get("/", (_req: Request, res: Response) => {
  res.json({ message: "Hello World" });
});

app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});

{{< /file >}}

{{< file path="package.json" >}}

{
  "name": "nodejs-docker-example",
  "version": "1.0.0",
  "description": "A minimal Node.js TypeScript application.",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts"
  },
  "dependencies": {
    "express": "^4.21.2"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^22.0.0",
    "tsx": "^4.19.3",
    "typescript": "^5.8.3"
  }
}

{{< /file >}}

{{< file path="tsconfig.json" >}}

{
  // TypeScript compiler configuration for the Node.js application.
  // Compiles src/ to dist/ as CommonJS modules targeting ES2022.
  // See https://www.typescriptlang.org/tsconfig/ for all options.
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

{{< /file >}}

{{< file path="Dockerfile" status="new" >}}

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/

# Builder stage: install all dependencies and compile TypeScript.
FROM dhi.io/node:24-alpine3.23-dev AS builder

WORKDIR /app

# Install dependencies as a separate step to take advantage of Docker's
# caching. Leverage a cache mount to /root/.npm to speed up subsequent
# builds. Leverage a bind mount to package.json to avoid having to copy
# it into this layer.
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    npm install
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
#     --mount=type=bind,source=package.json,target=package.json \
#     --mount=type=bind,source=package-lock.json,target=package-lock.json \
#     npm ci

# Copy the source code into the container and compile TypeScript.
COPY . .
RUN npm run build


# Deps stage: install production dependencies only.
FROM dhi.io/node:24-alpine3.23-dev AS deps

WORKDIR /app

RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    npm install --omit=dev
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
#     --mount=type=bind,source=package.json,target=package.json \
#     --mount=type=bind,source=package-lock.json,target=package-lock.json \
#     npm ci --omit=dev


# Runner stage: minimal runtime image with compiled app and production deps.
FROM dhi.io/node:24-alpine3.23 AS runner

ENV PATH=/app/node_modules/.bin:$PATH

WORKDIR /app

COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist

# Expose the port that the application listens on.
EXPOSE 3000

# Run the application.
CMD ["node", "dist/index.js"]

{{< /file >}}

{{< file path="compose.yaml" status="new" >}}

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  server:
    build:
      context: .
    ports:
      - 3000:3000

{{< /file >}}

{{< file path=".dockerignore" status="new" >}}

# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

node_modules/
dist/
.env
.git
.gitignore
.DS_Store
npm-debug.log*
coverage/
db/

{{< /file >}}

{{< file path=".gitignore" >}}

# Files and directories that Git should ignore. Covers Node.js dependencies,
# TypeScript build output, environment files, and common editor artifacts.
# See https://git-scm.com/docs/gitignore for syntax reference.

node_modules/
dist/
.env
*.log
.DS_Store
coverage/
db/password.txt

{{< /file >}}

{{< /files >}}

The Dockerfile uses three stages. The builder stage installs all dependencies and compiles TypeScript. The deps stage does a fresh install of production-only dependencies. The runner stage copies the compiled output and production node_modules into a minimal runtime image that contains only Node.js.

To learn more about each file, see the following:

Run the application

Inside the nodejs-docker-example directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:3000. You should see {"message":"Hello World"}.

In the terminal, press ctrl+c to stop the application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the nodejs-docker-example directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:3000.

In the terminal, run the following command to stop the application.

$ docker compose down

For more information about Compose commands, see the Compose CLI reference.

Use containers for Node.js development

Prerequisites

Complete Containerize a Node.js application.

Overview

Once your application runs in a container, the next step is making the container part of your everyday development workflow. Code changes should show up quickly, and services your app depends on, like databases, should run right alongside it.

In this section, you'll adapt the Dockerfile for local development by renaming the builder stage to dev and pointing Compose at it. You'll also update the application to connect to a PostgreSQL database, add a database service to compose.yaml, persist data in a named volume, enable Compose Watch so changes in your editor are picked up without a manual rebuild, and set up Node.js debugging so you can attach VS Code or Chrome DevTools to the running container.

Update the application

You'll update your application to connect to a PostgreSQL database. Continue working in your nodejs-docker-example directory.

Replace src/index.ts and package.json with the following contents. The file browser shows only the files that change in this step.

[!NOTE]

The application won't run yet after this step. It tries to connect to a PostgreSQL database that doesn't exist. The next two sections add the database service and the Docker configuration needed to run everything together.

{{< files name="nodejs-docker-example" >}}

{{< file path="src/index.ts" status="modified" >}}

// Express application backed by a PostgreSQL database.
// Creates a heroes table at startup.
// Endpoints: GET / (greeting), GET /health (health check), POST /heroes/ (create), GET /heroes/ (list).
// See https://expressjs.com/ and https://node-postgres.com/

import express, { type Request, type Response } from "express";
import { Pool } from "pg";
import { readFileSync } from "fs";

const app = express();
const port = parseInt(process.env.PORT ?? "3000", 10);

app.use(express.json());

function getPassword(): string {
  const passwordFile = process.env.POSTGRES_PASSWORD_FILE;
  if (passwordFile) {
    return readFileSync(passwordFile, "utf8").trim();
  }
  return process.env.POSTGRES_PASSWORD ?? "";
}

const pool = new Pool({
  host: process.env.POSTGRES_SERVER,
  port: 5432,
  database: process.env.POSTGRES_DB,
  user: process.env.POSTGRES_USER,
  password: getPassword(),
});

pool
  .query(
    `CREATE TABLE IF NOT EXISTS heroes (
      id SERIAL PRIMARY KEY,
      name TEXT NOT NULL,
      secret_name TEXT NOT NULL,
      age INTEGER
    )`,
  )
  .catch(console.error);

app.get("/", (_req: Request, res: Response) => {
  res.json({ message: "Hello World" });
});

app.get("/health", (_req: Request, res: Response) => {
  res.json({ status: "ok" });
});

app.post("/heroes/", async (req: Request, res: Response) => {
  const { name, secret_name, age } = req.body as {
    name: string;
    secret_name: string;
    age?: number;
  };
  const result = await pool.query(
    "INSERT INTO heroes (name, secret_name, age) VALUES ($1, $2, $3) RETURNING *",
    [name, secret_name, age],
  );
  res.json(result.rows[0]);
});

app.get("/heroes/", async (_req: Request, res: Response) => {
  const result = await pool.query("SELECT * FROM heroes");
  res.json(result.rows);
});

app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});

{{< /file >}}

{{< file path="package.json" status="modified" hl_lines="13,18" >}}

{
  "name": "nodejs-docker-example",
  "version": "1.0.0",
  "description": "A minimal Node.js TypeScript application.",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts"
  },
  "dependencies": {
    "express": "^4.21.2",
    "pg": "^8.16.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^22.0.0",
    "@types/pg": "^8.11.0",
    "tsx": "^4.19.3",
    "typescript": "^5.8.3"
  }
}

{{< /file >}}

{{< /files >}}

Update Docker assets

Replace Dockerfile and compose.yaml with the following.

{{< files name="nodejs-docker-example" >}}

{{< file path="Dockerfile" status="modified" hl_lines="12,34,37,63" >}}

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/

# Development stage: install all dependencies, compile TypeScript, and
# serve with hot-reload. Used directly in development via compose.yaml.
FROM dhi.io/node:24-alpine3.23-dev AS dev

WORKDIR /app

# Install dependencies as a separate step to take advantage of Docker's
# caching. Leverage a cache mount to /root/.npm to speed up subsequent
# builds. Leverage a bind mount to package.json to avoid having to copy
# it into this layer.
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    npm install
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
#     --mount=type=bind,source=package.json,target=package.json \
#     --mount=type=bind,source=package-lock.json,target=package-lock.json \
#     npm ci

# Copy the source code into the container and compile TypeScript.
COPY . .
RUN npm run build

# Expose the port that the application listens on.
EXPOSE 3000

# Run the application in development mode.
CMD ["npm", "run", "dev"]


# Deps stage: install production dependencies only.
FROM dhi.io/node:24-alpine3.23-dev AS deps

WORKDIR /app

RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    npm install --omit=dev
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
#     --mount=type=bind,source=package.json,target=package.json \
#     --mount=type=bind,source=package-lock.json,target=package-lock.json \
#     npm ci --omit=dev


# Runner stage: minimal runtime image with compiled app and production deps.
FROM dhi.io/node:24-alpine3.23 AS runner

ENV PATH=/app/node_modules/.bin:$PATH

WORKDIR /app

COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=dev --chown=node:node /app/dist ./dist

# Expose the port that the application listens on.
EXPOSE 3000

# Run the application.
CMD ["node", "dist/index.js"]

{{< /file >}}

{{< file path="compose.yaml" status="modified" hl_lines="8" >}}

services:
  # Application service. The `target: dev` line builds the development
  # image (includes tsx and dev tooling); the runner stage of the
  # Dockerfile is unused in development.
  server:
    build:
      context: .
      target: dev
    ports:
      - 3000:3000

{{< /file >}}

{{< /files >}}

About these changes

The builder stage from containerize is renamed to dev and gains EXPOSE 3000 and CMD ["npm", "run", "dev"], which runs tsx watch for hot-reload. The deps and runner stages are unchanged.

In compose.yaml, the new target: dev line tells Compose to build and run the dev stage during development. Unlike the production image, the development image includes tsx and other dev tooling. If you need a shell in a running production container, use Docker Debug instead.

The build step runs tsc, which compiles each TypeScript file into a corresponding JavaScript file. esbuild is a popular alternative that bundles everything into a single output file and builds significantly faster. To switch, replace the tsc call in package.json with an esbuild command and update the COPY --from=dev path in the runner stage to match esbuild's output.

Add a local database and persist data

You can use containers to set up local services, like a database. In this section, you'll update the compose.yaml file to define a database service and a volume to persist data, and add a db/password.txt file that holds the database password.

{{< files name="nodejs-docker-example" >}}

{{< file path="compose.yaml" status="modified" hl_lines="11-46" >}}

services:
  # Application service. The `target: dev` line builds the development
  # image (includes tsx and dev tooling); the runner stage of the
  # Dockerfile is unused in development.
  server:
    build:
      context: .
      target: dev
    ports:
      - 3000:3000
    environment:
      - POSTGRES_SERVER=db
      - POSTGRES_USER=postgres
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
  # Database service. Reads the password from a Docker secret mounted at
  # /run/secrets/db-password. Compose waits for the healthcheck to pass
  # before starting the server, via the server's depends_on.
  db:
    image: dhi.io/postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

{{< /file >}}

{{< file path="db/password.txt" status="new" >}}

mysecretpassword

{{< /file >}}

{{< /files >}}

[!NOTE]

To learn more about the instructions in the Compose file, see Compose file reference.

Now, run the following docker compose up command to start your application.

$ docker compose up --build

Now test your API endpoint. Open a new terminal and make a request to the server using the curl commands.

Create an object with a POST request:

$ curl -X 'POST' \
  'http://localhost:3000/heroes/' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "my hero",
  "secret_name": "austing",
  "age": 12
}'

You should receive the following response:

{
  "id": 1,
  "name": "my hero",
  "secret_name": "austing",
  "age": 12
}

Now make a GET request:

$ curl http://localhost:3000/heroes/

You should receive the same response because it's the only object in the database.

Press ctrl+c in the terminal to stop your application.

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Open your compose.yaml file in an IDE or text editor and add the highlighted Compose Watch instructions.

{{< files name="nodejs-docker-example" >}}

{{< file path="compose.yaml" status="modified" hl_lines="21-27" >}}

services:
  # Application service. The `target: dev` line builds the development
  # image (includes tsx and dev tooling); the runner stage of the
  # Dockerfile is unused in development.
  server:
    build:
      context: .
      target: dev
    ports:
      - 3000:3000
    environment:
      - POSTGRES_SERVER=db
      - POSTGRES_USER=postgres
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json
  db:
    image: dhi.io/postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

{{< /file >}}

{{< /files >}}

Run the following command to run your application with Compose Watch.

$ docker compose watch

In a terminal, curl the application to get a response.

$ curl http://localhost:3000
{"message":"Hello World"}

Any changes to the application's source files on your local machine will now be immediately reflected in the running container.

Open nodejs-docker-example/src/index.ts in an IDE or text editor and update the Hello World string by adding a few more exclamation marks.

-  res.json({ message: 'Hello World' });
+  res.json({ message: 'Hello World!!!' });

Save the changes to src/index.ts and then wait a few seconds for the application to reload. Curl the application again and verify that the updated text appears.

$ curl http://localhost:3000
{"message":"Hello World!!!"}

Press ctrl+c in the terminal to stop your application.

Debug your application

tsx watch supports the Node.js inspector protocol, so you can attach a debugger from VS Code or Chrome DevTools and set breakpoints directly in your TypeScript source files.

Update the dev script in package.json to start the inspector. The --inspect=0.0.0.0:9229 flag tells Node.js to listen for debugger connections on all network interfaces at port 9229. Using 0.0.0.0 rather than localhost is necessary so the debugger is reachable from outside the container. Also expose the debug port in compose.yaml, and add a .vscode/launch.json file that tells VS Code how to attach to the running inspector.

{{< files name="nodejs-docker-example" >}}

{{< file path="package.json" status="modified" hl_lines="9" >}}

{
  "name": "nodejs-docker-example",
  "version": "1.0.0",
  "description": "A minimal Node.js TypeScript application.",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch --inspect=0.0.0.0:9229 src/index.ts"
  },
  "dependencies": {
    "express": "^4.21.2",
    "pg": "^8.16.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^22.0.0",
    "@types/pg": "^8.11.0",
    "tsx": "^4.19.3",
    "typescript": "^5.8.3"
  }
}

{{< /file >}}

{{< file path=".vscode/launch.json" status="new" >}}

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Attach to Docker Container",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "address": "localhost",
      "localRoot": "${workspaceFolder}",
      "remoteRoot": "/app",
      "protocol": "inspector",
      "restart": true,
      "sourceMaps": true,
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}

{{< /file >}}

{{< file path="compose.yaml" status="modified" hl_lines="11" >}}

services:
  # Application service. The `target: dev` line builds the development
  # image (includes tsx and dev tooling); the runner stage of the
  # Dockerfile is unused in development.
  server:
    build:
      context: .
      target: dev
    ports:
      - 3000:3000
      - 9229:9229
    environment:
      - POSTGRES_SERVER=db
      - POSTGRES_USER=postgres
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json
  db:
    image: dhi.io/postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

{{< /file >}}

{{< /files >}}

Rebuild and restart with the updated configuration:

$ docker compose up --build

When the inspector is ready, you'll see a line like the following in the logs:

Debugger listening on ws://0.0.0.0:9229/...

VS Code

With .vscode/launch.json in place, attach the debugger using the Debug panel.

Open the Debug panel (Ctrl+Shift+D on Windows and Linux, Cmd+Shift+D on Mac), select Attach to Docker Container, and press F5. You can now set breakpoints in your TypeScript source files under src/.

Chrome DevTools

You can also use the built-in Node.js inspector in Chrome without any editor setup.

  1. Open Chrome and go to chrome://inspect.

  2. Select Configure and add localhost:9229.

  3. When your Node.js target appears in the list, select inspect.

Troubleshoot the debugger

If the debugger doesn't connect, verify the container is running and the port is mapped correctly:

$ docker compose ps
$ docker compose logs server

The logs should include a line like:

Debugger listening on ws://0.0.0.0:9229/...

If that line is missing, confirm the dev script in package.json includes --inspect=0.0.0.0:9229 and that 9229:9229 appears in the ports list for the server service in compose.yaml.

For more details about Node.js debugging, see the Node.js debugging guide.

Run Node.js tests in a container

Prerequisites

Complete all the previous sections of this guide, starting with Containerize a Node.js application.

Overview

Testing is a core part of building reliable software. Docker makes it easy to run your tests in the same environment used in CI and production, so failures are caught before they reach your users.

In this section, you'll add Vitest to the project and run tests both locally and inside a container.

Update the application

You'll refactor src/index.ts to export the Express app instance so tests can import it without starting a server. Add a test file and update package.json to add Vitest and a test runner for HTTP requests. The file browser shows only the files that change in this step.

{{< files name="nodejs-docker-example" >}}

{{< file path="src/index.ts" status="modified" hl_lines="10,31,70-75" >}}

// Express application backed by a PostgreSQL database.
// Creates a heroes table at startup.
// Endpoints: GET / (greeting), GET /health (health check), POST /heroes/ (create), GET /heroes/ (list).
// See https://expressjs.com/ and https://node-postgres.com/

import express, { type Request, type Response } from "express";
import { Pool } from "pg";
import { readFileSync } from "fs";

export const app = express();
const port = parseInt(process.env.PORT ?? "3000", 10);

app.use(express.json());

function getPassword(): string {
  const passwordFile = process.env.POSTGRES_PASSWORD_FILE;
  if (passwordFile) {
    return readFileSync(passwordFile, "utf8").trim();
  }
  return process.env.POSTGRES_PASSWORD ?? "";
}

const pool = new Pool({
  host: process.env.POSTGRES_SERVER,
  port: 5432,
  database: process.env.POSTGRES_DB,
  user: process.env.POSTGRES_USER,
  password: getPassword(),
});

if (process.env.POSTGRES_SERVER) {
  pool
    .query(
      `CREATE TABLE IF NOT EXISTS heroes (
        id SERIAL PRIMARY KEY,
        name TEXT NOT NULL,
        secret_name TEXT NOT NULL,
        age INTEGER
      )`,
    )
    .catch(console.error);
}

app.get("/", (_req: Request, res: Response) => {
  res.json({ message: "Hello World" });
});

app.get("/health", (_req: Request, res: Response) => {
  res.json({ status: "ok" });
});

app.post("/heroes/", async (req: Request, res: Response) => {
  const { name, secret_name, age } = req.body as {
    name: string;
    secret_name: string;
    age?: number;
  };
  const result = await pool.query(
    "INSERT INTO heroes (name, secret_name, age) VALUES ($1, $2, $3) RETURNING *",
    [name, secret_name, age],
  );
  res.json(result.rows[0]);
});

app.get("/heroes/", async (_req: Request, res: Response) => {
  const result = await pool.query("SELECT * FROM heroes");
  res.json(result.rows);
});

// Only start the server when this file is run directly.
if (require.main === module) {
  app.listen(port, () => {
    console.log(`Server listening on port ${port}`);
  });
}

{{< /file >}}

{{< file path="src/index.test.ts" status="new" >}}

// Unit tests for the Express application.
// Tests the root endpoint without starting a server.
// See https://vitest.dev/ for the test framework reference.

import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "./index";

describe("GET /", () => {
  it("returns a JSON greeting", async () => {
    const response = await request(app).get("/");
    expect(response.status).toBe(200);
    expect(response.body).toEqual({ message: "Hello World" });
  });
});

{{< /file >}}

{{< file path="package.json" status="modified" hl_lines="10,20-22" >}}

{
  "name": "nodejs-docker-example",
  "version": "1.0.0",
  "description": "A minimal Node.js TypeScript application.",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "test": "vitest run"
  },
  "dependencies": {
    "express": "^4.21.2",
    "pg": "^8.16.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^22.0.0",
    "@types/pg": "^8.11.0",
    "supertest": "^7.0.0",
    "@types/supertest": "^6.0.0",
    "tsx": "^4.19.3",
    "typescript": "^5.8.3",
    "vitest": "^3.0.0"
  }
}

{{< /file >}}

{{< /files >}}

Run tests locally

Run the following command to run the tests locally:

$ npm install
$ npm test

You should see output like the following:

 RUN  v3.0.0 /app

 ✓ src/index.test.ts (1)
   ✓ GET / (1)
     ✓ returns a JSON greeting

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  12:00:00
   Duration  500ms

Run tests in a container

Run the tests using the dev stage of your Dockerfile:

$ docker compose run --build --rm --no-deps server npm test

The --no-deps flag skips starting the database, since the unit tests don't require it. The --rm flag removes the container when the tests finish.

You should see the same test output as when running locally.

Run tests when building

To run tests during the Docker build process, add a test stage to your Dockerfile that runs after the dev stage.

FROM dhi.io/node:24-alpine3.23-dev AS dev

WORKDIR /app

RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    npm install

COPY . .
RUN npm run build

EXPOSE 3000
CMD ["npm", "run", "dev"]


FROM dhi.io/node:24-alpine3.23-dev AS deps
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    npm install --omit=dev

FROM dhi.io/node:24-alpine3.23 AS runner
ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=dev --chown=node:node /app/dist ./dist

EXPOSE 3000
CMD ["node", "dist/index.js"]


FROM dev AS test

ENV CI=true

CMD ["npm", "test"]

Then build and run the test stage:

$ docker build --target test -t nodejs-app-test .
$ docker run --rm nodejs-app-test

Summary

In this section, you learned how to run tests when developing locally and inside a container.

Use OpenCode with Docker Model Runner

This guide shows how to connect OpenCode to Docker Model Runner so OpenCode can use local models for coding tasks. You'll configure an opencode.json file, verify the API endpoint, and run OpenCode against models served from your local Docker environment.

Acknowledgment

Docker would like to thank Pradumna Saraf for his contribution to this guide.

Unlike the OpenCode sandbox guide, this guide focuses on using OpenCode as a local coding tool backed by Docker Model Runner rather than running OpenCode in a containerized sandbox.

In this guide, you'll learn how to:

Prerequisites

Before you start, make sure you have:

If you use Docker Desktop, turn on TCP access in Settings > AI, or run:

$ docker desktop enable model-runner --tcp 12434

Step 1: Pull a coding model

Pull one or more models before you configure OpenCode:

$ docker model pull ai/qwen3-coder
$ docker model pull ai/devstral-small-2

These models are a good fit for coding workflows because they support large context windows.

Step 2: Create an OpenCode configuration

OpenCode reads configuration from either of these locations:

Project-level configuration overrides the global file.

Add a provider that points to Docker Model Runner:

{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "dmr": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Docker Model Runner",
      "options": {
        "baseURL": "http://localhost:12434/v1"
      },
      "models": {
        "qwen3-coder": {
          "name": "ai/qwen3-coder"
        },
        "devstral-small-2": {
          "name": "ai/devstral-small-2"
        }
      }
    }
  }
}

This configuration adds Docker Model Runner as an OpenCode provider and exposes two local models.

[!NOTE]

If your setup expects the older OpenAI-compatible path, use http://localhost:12434/engines/v1 instead.

Step 3: Verify the endpoint

Check that Docker Model Runner is reachable before you open OpenCode:

$ curl http://localhost:12434/v1/models

If you use the older path, run:

$ curl http://localhost:12434/engines/v1/models

The response should list the models available through Docker Model Runner.

Step 4: Start OpenCode

From your project directory, run:

$ opencode

To switch models from the TUI, run:

/models

Then select the model from the dmr provider.

Step 5: Package gpt-oss with a larger context window

This step is optional. Use it if you need a larger context window for repository-scale tasks.

gpt-oss defaults to a smaller context window than coding-focused models. If you want to use it for repository-scale tasks, package a larger variant:

$ docker model pull ai/gpt-oss
$ docker model package --from ai/gpt-oss --context-size 128000 gpt-oss:128k

Then add it to your OpenCode configuration:

{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "dmr": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Docker Model Runner",
      "options": {
        "baseURL": "http://localhost:12434/v1"
      },
      "models": {
        "gpt-oss:128k": {
          "name": "gpt-oss:128k"
        }
      }
    }
  }
}

Troubleshooting

If OpenCode can't connect, check Docker Model Runner status:

$ docker model status

If OpenCode does not show your model, list local models:

$ docker model ls

If the model is missing, pull it first and confirm the model name in opencode.json matches the local model you want to use.

Learn more

Instrumenting a JavaScript App with OpenTelemetry

OpenTelemetry (OTel) is an open-source observability framework that provides a set of APIs, SDKs, and tools for collecting telemetry data, such as metrics, logs, and traces, from applications. With OpenTelemetry, developers can obtain valuable insights into how their services perform in production or during local development.

A key component of OpenTelemetry is the OpenTelemetry Protocol (OTLP) a general-purpose, vendor-agnostic protocol designed to transmit telemetry data efficiently and reliably. OTLP supports multiple data types (traces, metrics, logs) over HTTP or gRPC, making it the default and recommended protocol for communication between instrumented applications, the OpenTelemetry Collector, and backends such as Jaeger or Prometheus.

This guide walks you through how to instrument a simple Node.js application with OpenTelemetry and run both the app and a collector using Docker. This setup is ideal for local development and testing observability before integrating with external observability platforms like Prometheus, Jaeger, or Grafana.

In this guide, you'll learn how to:

Using OpenTelemetry with Docker

The Docker Official Image for OpenTelemetry provides a convenient way to deploy and manage Dex instances. OpenTelemetry is available for various CPU architectures, including amd64, armv7, and arm64, ensuring compatibility with different devices and platforms. Same for the Jaeger Docker image.

Prerequisites

Docker Compose: Recommended for managing multi-container Docker applications.

Basic knowledge of Node.js and Docker.

Project structure

Create the project directory:

mkdir otel-js-app
cd otel-js-app
otel-js-app/
├── docker-compose.yaml
├── collector-config.yaml
├── app/
│   ├── package.json
│   ├── app.js
│   └── tracer.js

Create a simple Node.js app

Initialize a basic Node.js app:

mkdir app && cd app
npm init -y
npm install express @opentelemetry/api @opentelemetry/sdk-node \
            @opentelemetry/auto-instrumentations-node \
            @opentelemetry/exporter-trace-otlp-http

Now, add the application logic:

// app/app.js
const express = require('express');
require('./tracer'); // Initialize OpenTelemetry

const app = express();

app.get('/', (req, res) => {
  res.send('Hello from OpenTelemetry demo app!');
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`App listening at http://localhost:${PORT}`);
});

Configure OpenTelemetry tracing

Create the tracer configuration file:

// app/tracer.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: 'http://collector:4318/v1/traces',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

Configure the OpenTelemetry Collector

Create a collector-config.yaml file at the root:

# collector-config.yaml
receivers:
  otlp:
    protocols:
      http:

exporters:
  logging:
    loglevel: debug
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [logging, jaeger]

Add Docker Compose configuration

Create the docker-compose.yaml file:

services:
  app:
    build: ./app
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
    depends_on:
      - collector

  collector:
    image: otel/opentelemetry-collector:latest
    volumes:
      - ./collector-config.yaml:/etc/otelcol/config.yaml
    command: ["--config=/etc/otelcol/config.yaml"]
    ports:
      - "4318:4318" # OTLP

  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # UI
      - "14250:14250" # Collector gRPC

Now, add the Dockerfile inside the app/ folder:

# app/Dockerfile
FROM node:18

WORKDIR /usr/src/app
COPY . .
RUN npm install

CMD ["node", "app.js"]

Start the stack

Start all services with Docker Compose:

docker compose up --build

Once the services are running:

Visit your app at http://localhost:3000

View traces at http://localhost:16686 in the Jaeger UI

Verify traces in Jaeger

After visiting your app's root endpoint, open Jaeger’s UI, search for the service (default is usually unknown_service unless explicitly named), and check the traces.

You should see spans for the HTTP request, middleware, and auto-instrumented libraries.

Conclusion

You now have a fully functional OpenTelemetry setup using Docker Compose. You've instrumented a basic JavaScript app to export traces and visualized them using Jaeger. This architecture is extendable for more complex applications and observability pipelines using Prometheus, Grafana, or cloud-native exporters.

For advanced topics such as custom span creation, metrics, and logs, consult the OpenTelemetry JavaScript docs.

Visualizing your PostgreSQL databases with pgAdmin

Many applications use PostgreSQL databases in the application stack. However, not all developers are knowledgeable about navigating and working with PostgreSQL databases.

Fortunately, when you use containers in development, it is easy to add additional services to help with troubleshooting and debugging.

The pgAdmin tool is a popular open-source tool designed to help administer and visualize PostgreSQL databases.

In this guide you will learn how to:

  1. Add pgAdmin to your application stack
  2. Configure pgAdmin to automatically connect to the development database

Adding pgAdmin to your stack

  1. In your compose.yaml file, add the pgadmin service next to your existing postgres service:

    services:
      postgres:
        image: postgres:18
        environment:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: secret
          POSTGRES_DB: demo
    
      pgadmin:
        image: dpage/pgadmin4:9.8
        ports:
          - 5050:80
        environment:
          # Required by pgAdmin
          PGADMIN_DEFAULT_EMAIL: demo@example.com
          PGADMIN_DEFAULT_PASSWORD: secret
    
          # Don't require the user to login
          PGADMIN_CONFIG_SERVER_MODE: 'False'
    
          # Don't require a "master" password after logging in
          PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
    
  2. Start the Compose stack with the following command:

    $ docker compose up
    

    After the image is downloaded the container starts, you will see output that looks similar to the following indicating pgAdmin is ready:

    pgadmin-1   | [2025-09-22 15:52:47 +0000] [1] [INFO] Starting gunicorn 23.0.0
    pgadmin-1   | [2025-09-22 15:52:47 +0000] [1] [INFO] Listening at: http://[::]:80 (1)
    pgadmin-1   | [2025-09-22 15:52:47 +0000] [1] [INFO] Using worker: gthread
    pgadmin-1   | [2025-09-22 15:52:47 +0000] [119] [INFO] Booting worker with pid: 119
    
  3. Open pgAdmin by going to http://localhost:5050.

  4. Once in the admin panel, select the Add New Server link to define a new server. Enter the following details:

    • General tab:
      • Name: postgres
    • Connection tab:
      • Host name/address: postgres
      • Username: postgres
      • Password: secret
      • Enable the Save password? field

    [!IMPORTANT]

    These connection details assume you are using the previous Compose file snippet. If you are using an existing Compose file, adjust the connection details as required. The Host name/address field should match the name of your postgres service.

  5. Select the Save button to create the new database.

You now have pgAdmin setup and connected to your containerized database. Feel free to navigate around, view the tables, and explore your database.

Configuring pgAdmin to auto-connect to the database

Although you have pgAdmin running, it would be nice if you could simply open the app without needing to configure the database connection. Reducing the setup steps would be a great way to make it easier for teammates to get value from this tool.

Fortunately, there is an ability to auto-connect to the database.

[!WARNING]

In order to auto-connect, the database credentials are shared using plaintext files. During local development, this is often acceptable as local data is not real customer data. However, if you are using production or sensitive data, this practice is strongly discouraged.

  1. First, you need to define the server itself, which pgAdmin does using a servers.json file.

    Add the following to your compose.yaml file to define a config file for the servers.json file:

    configs:
      pgadmin-servers:
        content: |
          {
            "Servers": {
              "1": {
                "Name": "Local Postgres",
                "Group": "Servers",
                "Host": "postgres",
                "Port": 5432,
                "MaintenanceDB": "postgres",
                "Username": "postgres",
                "PassFile": "/config/pgpass"
              }
            }
          }
    
  2. The servers.json file defines a PassFile field, which is a reference to a postgreSQL password files. These are often referred to as a pgpass file.

    Add the following config to your compose.yaml file to define a pgpass file:

    configs:
      pgadmin-pgpass:
        content: |
          postgres:5432:*:postgres:secret
    

    This will indicate any connection requests to postgres:5432 using the username postgres should provide a password of secret.

  3. In your compose.yaml, update the pgadmin service to inject the config files:

    services:
      pgadmin:
        ...
        configs:
          - source: pgadmin-pgpass
            target: /config/pgpass
            uid: "5050"
            gid: "5050"
            mode: 0400
          - source: pgadmin-servers
            target: /pgadmin4/servers.json
            mode: 0444
    
  4. Update the application stack by running docker compose up again:

    $ docker compose up
    
  5. Once the application is restarted, open your browser to http://localhost:5050. You should be able to access the database without any logging in or configuration.

Conclusion

Using containers makes it easy to not only run your application's dependencies, but also additional tools to help with troubleshooting and debugging.

When you add tools, think about the experience and possible friction your teammates might experience and how you might be able to remove it. In this case, you were able to take an extra step to add configuration to automatically configure and connect the databases, saving your teammates valuable time.

PHP language-specific guide

The PHP language-specific guide teaches you how to create a containerized PHP application using Docker. In this guide, you'll learn how to:

After completing the PHP language-specific guide, you should be able to containerize your own PHP application based on the examples and instructions provided in this guide.

Start by containerizing an existing PHP application.

Containerize a PHP application

Prerequisites

Overview

This section walks you through containerizing and running a PHP application.

Get the sample applications

In this guide, you will use a pre-built PHP application. The application uses Composer for library dependency management. You'll serve the application via an Apache web server.

Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository.

$ git clone https://github.com/docker/docker-php-sample

The sample application is a basic hello world application and an application that increments a counter in a database. In addition, the application uses PHPUnit for testing.

Create Docker assets

Now that you have an application, you can create the necessary Docker assets to containerize it.

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

Create the following files in your docker-php-sample directory.

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

################################################################################

# Create a stage for installing app dependencies defined in Composer.
FROM composer:lts as deps

WORKDIR /app

# If your composer.json file defines scripts that run during dependency installation and
# reference your application source files, uncomment the line below to copy all the files
# into this layer.
# COPY . .

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a bind mounts to composer.json and composer.lock to avoid having to copy them
# into this layer.
# Leverage a cache mount to /tmp/cache so that subsequent builds don't have to re-download packages.
RUN --mount=type=bind,source=composer.json,target=composer.json \
    --mount=type=bind,source=composer.lock,target=composer.lock \
    --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-interaction

################################################################################

# Create a new stage for running the application that contains the minimal
# runtime dependencies for the application. This often uses a different base
# image from the install or build stage where the necessary files are copied
# from the install stage.
#
# The example below uses the PHP Apache image as the foundation for running the app.
# By specifying the "8.2-apache" tag, it will also use whatever happens to be the
# most recent version of that tag when you build your Dockerfile.
# If reproducibility is important, consider using a specific digest SHA, like
# php@sha256:99cede493dfd88720b610eb8077c8688d3cca50003d76d1d539b0efc8cca72b4.
FROM php:8.2-apache as final

# Your PHP application may require additional PHP extensions to be installed
# manually. For detailed instructions for installing extensions can be found, see
# https://github.com/docker-library/docs/tree/master/php#how-to-install-more-php-extensions
# The following code blocks provide examples that you can edit and use.
#
# Add core PHP extensions, see
# https://github.com/docker-library/docs/tree/master/php#php-core-extensions
# This example adds the apt packages for the 'gd' extension's dependencies and then
# installs the 'gd' extension. For additional tips on running apt-get, see
# https://docs.docker.com/go/dockerfile-aptget-best-practices/
# RUN apt-get update && apt-get install -y \
#     libfreetype-dev \
#     libjpeg62-turbo-dev \
#     libpng-dev \
# && rm -rf /var/lib/apt/lists/* \
#     && docker-php-ext-configure gd --with-freetype --with-jpeg \
#     && docker-php-ext-install -j$(nproc) gd
#
# Add PECL extensions, see
# https://github.com/docker-library/docs/tree/master/php#pecl-extensions
# This example adds the 'redis' and 'xdebug' extensions.
# RUN pecl install redis-5.3.7 \
#    && pecl install xdebug-3.2.1 \
#    && docker-php-ext-enable redis xdebug

# Use the default production configuration for PHP runtime arguments, see
# https://github.com/docker-library/docs/tree/master/php#configuration
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

# Copy the app dependencies from the previous install stage.
COPY --from=deps app/vendor/ /var/www/html/vendor
# Copy the app files from the app directory.
COPY ./src /var/www/html

# Switch to a non-privileged user (defined in the base image) that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
USER www-data
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  server:
    build:
      context: .
    ports:
      - 9000:80

# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
#     depends_on:
#       db:
#         condition: service_healthy
#   db:
#     image: postgres
#     restart: always
#     user: postgres
#     secrets:
#       - db-password
#     volumes:
#       - db-data:/var/lib/postgresql/data
#     environment:
#       - POSTGRES_DB=example
#       - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
#     expose:
#       - 5432
#     healthcheck:
#       test: [ "CMD", "pg_isready" ]
#       interval: 10s
#       timeout: 5s
#       retries: 5
# volumes:
#   db-data:
# secrets:
#   db-password:
#     file: db/password.txt
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.y*ml
!**/composer.json
!**/composer.lock
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/vendor
LICENSE
README.md

You should now have the following contents in your docker-php-sample directory.

├── docker-php-sample/
│ ├── .git/
│ ├── src/
│ ├── tests/
│ ├── .dockerignore
│ ├── .gitignore
│ ├── compose.yaml
│ ├── composer.json
│ ├── composer.lock
│ ├── Dockerfile
│ └── README.md

To learn more about these files, see the following:

Run the application

Inside the docker-php-sample directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:9000/hello.php. You should see a simple hello world application.

In the terminal, press ctrl+c to stop the application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the docker-php-sample directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:9000/hello.php. You should see a simple hello world application.

In the terminal, run the following command to stop the application.

$ docker compose down

For more information about Compose commands, see the Compose CLI reference.

Use containers for PHP development

Prerequisites

Complete Containerize a PHP application.

Overview

In this section, you'll learn how to set up a development environment for your containerized application. This includes:

Add a local database and persist data

You can use containers to set up local services, like a database. To do this for the sample application, you'll need to do the following:

Update the Dockerfile to install extensions

To install PHP extensions, you need to update the Dockerfile. Open your Dockerfile in an IDE or text editor and then update the contents. The following Dockerfile includes one new line that installs the pdo and pdo_mysql extensions. All comments have been removed.

# syntax=docker/dockerfile:1

FROM composer:lts as deps
WORKDIR /app
RUN --mount=type=bind,source=composer.json,target=composer.json \
    --mount=type=bind,source=composer.lock,target=composer.lock \
    --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-interaction

FROM php:8.2-apache as final
RUN docker-php-ext-install pdo pdo_mysql
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=deps app/vendor/ /var/www/html/vendor
COPY ./src /var/www/html
USER www-data

For more details about installing PHP extensions, see the Official Docker Image for PHP.

Update the compose.yaml file to add a db and persist data

Open the compose.yaml file in an IDE or text editor. You'll notice it already contains commented-out instructions for a PostgreSQL database and volume. For this application, you'll use MariaDB. For more details about MariaDB, see the MariaDB Official Docker image.

Open the src/database.php file in an IDE or text editor. You'll notice that it reads environment variables in order to connect to the database.

In the compose.yaml file, you'll need to update the following:

  1. Uncomment and update the database instructions for MariaDB.
  2. Add a secret to the server service to pass in the database password.
  3. Add the database connection environment variables to the server service.
  4. Uncomment the volume instructions to persist data.

The following is the updated compose.yaml file. All comments have been removed.

services:
  server:
    build:
      context: .
    ports:
      - 9000:80
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
    environment:
      - PASSWORD_FILE_PATH=/run/secrets/db-password
      - DB_HOST=db
      - DB_NAME=example
      - DB_USER=root
  db:
    image: mariadb
    restart: always
    user: root
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/mysql
    environment:
      - MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-password
      - MARIADB_DATABASE=example
    expose:
      - 3306
    healthcheck:
      test:
        [
          "CMD",
          "/usr/local/bin/healthcheck.sh",
          "--su-mysql",
          "--connect",
          "--innodb_initialized",
        ]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

[!NOTE]

To learn more about the instructions in the Compose file, see Compose file reference.

Before you run the application using Compose, notice that this Compose file uses secrets and specifies a password.txt file to hold the database's password. You must create this file as it's not included in the source repository.

In the docker-php-sample directory, create a new directory named db and inside that directory create a file named password.txt. Open password.txt in an IDE or text editor and add the following password. The password must be on a single line, with no additional lines in the file.

example

Save and close the password.txt file.

You should now have the following in your docker-php-sample directory.

├── docker-php-sample/
│ ├── .git/
│ ├── db/
│ │ └── password.txt
│ ├── src/
│ ├── tests/
│ ├── .dockerignore
│ ├── .gitignore
│ ├── compose.yaml
│ ├── composer.json
│ ├── composer.lock
│ ├── Dockerfile
│ └── README.md

Run the following command to start your application.

$ docker compose up --build

Open a browser and view the application at http://localhost:9000/database.php. You should see a simple web application with text and a counter that increments every time you refresh.

Press ctrl+c in the terminal to stop your application.

Verify that data persists in the database

In the terminal, run docker compose rm to remove your containers and then run docker compose up to run your application again.

$ docker compose rm
$ docker compose up --build

Refresh http://localhost:9000/database.php in your browser and verify that the previous count still exists. Without a volume, the database data wouldn't persist after you remove the container.

Press ctrl+c in the terminal to stop your application.

Add phpMyAdmin to interact with the database

You can easily add services to your application stack by updating the compose.yaml file.

Update your compose.yaml to add a new service for phpMyAdmin. For more details, see the phpMyAdmin Official Docker Image. The following is the updated compose.yaml file.

services:
  server:
    build:
      context: .
    ports:
      - 9000:80
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
    environment:
      - PASSWORD_FILE_PATH=/run/secrets/db-password
      - DB_HOST=db
      - DB_NAME=example
      - DB_USER=root
  db:
    image: mariadb
    restart: always
    user: root
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/mysql
    environment:
      - MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-password
      - MARIADB_DATABASE=example
    expose:
      - 3306
    healthcheck:
      test:
        [
          "CMD",
          "/usr/local/bin/healthcheck.sh",
          "--su-mysql",
          "--connect",
          "--innodb_initialized",
        ]
      interval: 10s
      timeout: 5s
      retries: 5
  phpmyadmin:
    image: phpmyadmin
    ports:
      - 8080:80
    depends_on:
      - db
    environment:
      - PMA_HOST=db
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

In the terminal, run docker compose up to run your application again.

$ docker compose up --build

Open http://localhost:8080 in your browser to access phpMyAdmin. Log in using root as the username and example as the password. You can now interact with the database through phpMyAdmin.

Press ctrl+c in the terminal to stop your application.

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Open your compose.yaml file in an IDE or text editor and then add the Compose Watch instructions. The following is the updated compose.yaml file.

services:
  server:
    build:
      context: .
    ports:
      - 9000:80
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
    environment:
      - PASSWORD_FILE_PATH=/run/secrets/db-password
      - DB_HOST=db
      - DB_NAME=example
      - DB_USER=root
    develop:
      watch:
        - action: sync
          path: ./src
          target: /var/www/html
  db:
    image: mariadb
    restart: always
    user: root
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/mysql
    environment:
      - MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-password
      - MARIADB_DATABASE=example
    expose:
      - 3306
    healthcheck:
      test:
        [
          "CMD",
          "/usr/local/bin/healthcheck.sh",
          "--su-mysql",
          "--connect",
          "--innodb_initialized",
        ]
      interval: 10s
      timeout: 5s
      retries: 5
  phpmyadmin:
    image: phpmyadmin
    ports:
      - 8080:80
    depends_on:
      - db
    environment:
      - PMA_HOST=db
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

Run the following command to run your application with Compose Watch.

$ docker compose watch

Open a browser and verify that the application is running at http://localhost:9000/hello.php.

Any changes to the application's source files on your local machine will now be immediately reflected in the running container.

Open hello.php in an IDE or text editor and update the string Hello, world! to Hello, Docker!.

Save the changes to hello.php and then wait a few seconds for the application to sync. Refresh http://localhost:9000/hello.php in your browser and verify that the updated text appears.

Press ctrl+c in the terminal to stop Compose Watch. Run docker compose down in the terminal to stop the application.

Create a development container

At this point, when you run your containerized application, Composer isn't installing the dev dependencies. While this small image is good for production, it lacks the tools and dependencies you may need when developing and it doesn't include the tests directory. You can use multi-stage builds to build stages for both development and production in the same Dockerfile. For more details, see Multi-stage builds.

In the Dockerfile, you'll need to update the following:

  1. Split the deps staged into two stages. One stage for production (prod-deps) and one stage (dev-deps) to install development dependencies.
  2. Create a common base stage.
  3. Create a new development stage for development.
  4. Update the final stage to copy dependencies from the new prod-deps stage.

The following is the Dockerfile before and after the changes.

{{< tabs >}} {{< tab name="Before" >}}

# syntax=docker/dockerfile:1

FROM composer:lts as deps
WORKDIR /app
RUN --mount=type=bind,source=composer.json,target=composer.json \
    --mount=type=bind,source=composer.lock,target=composer.lock \
    --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-interaction

FROM php:8.2-apache as final
RUN docker-php-ext-install pdo pdo_mysql
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=deps app/vendor/ /var/www/html/vendor
COPY ./src /var/www/html
USER www-data

{{< /tab >}} {{< tab name="After" >}}

# syntax=docker/dockerfile:1

FROM composer:lts as prod-deps
WORKDIR /app
RUN --mount=type=bind,source=./composer.json,target=composer.json \
    --mount=type=bind,source=./composer.lock,target=composer.lock \
    --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-interaction

FROM composer:lts as dev-deps
WORKDIR /app
RUN --mount=type=bind,source=./composer.json,target=composer.json \
    --mount=type=bind,source=./composer.lock,target=composer.lock \
    --mount=type=cache,target=/tmp/cache \
    composer install --no-interaction

FROM php:8.2-apache as base
RUN docker-php-ext-install pdo pdo_mysql
COPY ./src /var/www/html

FROM base as development
COPY ./tests /var/www/html/tests
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
COPY --from=dev-deps app/vendor/ /var/www/html/vendor

FROM base as final
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=prod-deps app/vendor/ /var/www/html/vendor
USER www-data

{{< /tab >}} {{< /tabs >}}

Update your compose.yaml file by adding an instruction to target the development stage.

The following is the updated section of the compose.yaml file.

services:
  server:
    build:
      context: .
      target: development
      # ...

Your containerized application will now install the dev dependencies.

Run the following command to start your application.

$ docker compose up --build

Open a browser and view the application at http://localhost:9000/hello.php. You should still see the simple "Hello, Docker!" application.

Press ctrl+c in the terminal to stop your application.

While the application appears the same, you can now make use of the dev dependencies. Continue to the next section to learn how you can run tests using Docker.

Run PHP tests in a container

Prerequisites

Complete all the previous sections of this guide, starting with Containerize a PHP application.

Overview

Testing is an essential part of modern software development. Testing can mean a lot of things to different development teams. There are unit tests, integration tests and end-to-end testing. In this guide you take a look at running your unit tests in Docker when developing and when building.

Run tests when developing locally

The sample application already has a PHPUnit test inside the tests directory. When developing locally, you can use Compose to run your tests.

Run the following command in the docker-php-sample directory to run the tests inside a container.

$ docker compose run --build --rm server ./vendor/bin/phpunit tests/HelloWorldTest.php

You should see output that contains the following.

Hello, Docker!PHPUnit 9.6.13 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 00:00.003, Memory: 4.00 MB

OK (1 test, 1 assertion)

To learn more about the command, see docker compose run.

Run tests when building

To run your tests when building, you need to update your Dockerfile. Create a new test stage that runs the tests.

The following is the updated Dockerfile.

# syntax=docker/dockerfile:1

FROM composer:lts as prod-deps
WORKDIR /app
RUN --mount=type=bind,source=./composer.json,target=composer.json \
    --mount=type=bind,source=./composer.lock,target=composer.lock \
    --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-interaction

FROM composer:lts as dev-deps
WORKDIR /app
RUN --mount=type=bind,source=./composer.json,target=composer.json \
    --mount=type=bind,source=./composer.lock,target=composer.lock \
    --mount=type=cache,target=/tmp/cache \
    composer install --no-interaction

FROM php:8.2-apache as base
RUN docker-php-ext-install pdo pdo_mysql
COPY ./src /var/www/html

FROM base as development
COPY ./tests /var/www/html/tests
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
COPY --from=dev-deps app/vendor/ /var/www/html/vendor

FROM development as test
WORKDIR /var/www/html
RUN ./vendor/bin/phpunit tests/HelloWorldTest.php

FROM base as final
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=prod-deps app/vendor/ /var/www/html/vendor
USER www-data

Run the following command to build an image using the test stage as the target and view the test results. Include --progress plain to view the build output, --no-cache to ensure the tests always run, and --target test to target the test stage.

$ docker build -t php-docker-image-test --progress plain --no-cache --target test .

You should see output containing the following.

#18 [test 2/2] RUN ./vendor/bin/phpunit tests/HelloWorldTest.php
#18 0.385 Hello, Docker!PHPUnit 9.6.13 by Sebastian Bergmann and contributors.
#18 0.392
#18 0.394 .                                                                   1 / 1 (100%)
#18 0.395
#18 0.395 Time: 00:00.003, Memory: 4.00 MB
#18 0.395
#18 0.395 OK (1 test, 1 assertion)

Summary

In this section, you learned how to run tests when developing locally using Compose and how to run tests when building your image.

PostgreSQL specific guide

This guide gets you from zero to a running PostgreSQL container in under five minutes, then explains how to keep your data safe across container restarts and removals.

Overview

Running PostgreSQL in Docker requires understanding one critical concept: containers are ephemeral, but your data shouldn't be. This guide covers:

Quick start (minimal viable container)

[!NOTE]

Docker Hardened Images (DHIs) are minimal, secure, and production-ready container base and application images maintained by Docker. DHIs are recommended whenever it is possible for better security. They are designed to reduce vulnerabilities and simplify compliance, freely available to everyone with no subscription required, no usage restrictions, and no vendor lock-in.

Run PostgreSQL immediately with this single command:

{{< tabs >}} {{< tab name="Using DHIs" >}}

You must authenticate to dhi.io before you can pull Docker Hardened Images. Run docker login dhi.io to authenticate.

docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -d dhi.io/postgres:18

{{< /tab >}}

{{< tab name="Using DOIs" >}}

$ docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -d postgres:18

{{< /tab >}} {{< /tabs >}}

Understanding the flags

Flag Purpose
--rm Automatically removes the container when it stops
--name postgres-dev Assigns a memorable name instead of a random string
-e POSTGRES_PASSWORD=... Sets the superuser password (required)
-p 5432:5432 Maps host port 5432 to container port 5432
-d Runs the container in the background (detached mode)

Verify the container is running:

$ docker ps --filter name=postgres-dev
CONTAINER ID   IMAGE         COMMAND                  STATUS         PORTS                    NAMES
a1b2c3d4e5f6   postgres:18   "docker-entrypoint.s…"   Up 2 seconds   0.0.0.0:5432->5432/tcp   postgres-dev

Connect using psql from inside the container:

$ docker exec -it postgres-dev psql -U postgres
psql (18.0)
Type "help" for help.

postgres=#

You now have a working PostgreSQL instance. But there's a problem—stop this container and your data disappears.

The data persistence problem

Containers use an ephemeral filesystem. When a container is removed, everything inside it, including your database files, is deleted.

Demonstrate this yourself:

{{< tabs >}} {{< tab name="Using DHIs" >}}

$ docker exec postgres-dev psql -U postgres -c "CREATE DATABASE testdb;"
CREATE DATABASE

$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
 testdb    | postgres | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           |

$ docker stop postgres-dev
postgres-dev

$ docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -d dhi.io/postgres:18

$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
(no output - database is gone)

{{< /tab >}}

{{< tab name="Using DOIs" >}}

$ docker exec postgres-dev psql -U postgres -c "CREATE DATABASE testdb;"
CREATE DATABASE

$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
 testdb    | postgres | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           |

$ docker stop postgres-dev
postgres-dev

$ docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -d postgres:18

$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
(no output - database is gone)

{{< /tab >}} {{< /tabs >}}

Your testdb database vanished because the new container started with a fresh filesystem. This is expected behavior—and exactly why volumes exist.

Named volumes

Named volumes are Docker-managed storage locations that persist independently of containers. Docker handles the filesystem location, permissions, and lifecycle.

Create a container with a named volume:

{{< tabs >}} {{< tab name="Using DHIs" >}}

You must authenticate to dhi.io before you can pull Docker Hardened Images. Run docker login dhi.io to authenticate.

$ docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -v postgres_data:/var/lib/postgresql \
  -d dhi.io/postgres:18

{{< /tab >}}

{{< tab name="Using DOIs" >}}

$ docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -v postgres_data:/var/lib/postgresql \
  -d postgres:18

{{< /tab >}} {{< /tabs >}}

The -v postgres_data:/var/lib/postgresql flag mounts a named volume called postgres_data to PostgreSQL's data directory. If the volume doesn't exist, Docker creates it automatically.

[!NOTE]

PostgreSQL 18+ stores data in a version-specific subdirectory under /var/lib/postgresql. Mounting at this level (rather than /var/lib/postgresql/data) allows for easier upgrades using pg_upgrade --link.

Verify persistence works

To verify data persistence, repeat the previous test, but this time with the named volume attached in place.

{{< tabs >}} {{< tab name="Using DHIs" >}}

$ docker exec postgres-dev psql -U postgres -c "CREATE DATABASE testdb;"
CREATE DATABASE

$ docker stop postgres-dev
postgres-dev

$ docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -v postgres_data:/var/lib/postgresql \
  -d dhi.io/postgres:18

$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
 testdb    | postgres | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           |

{{< /tab >}}

{{< tab name="Using DOIs" >}}

$ docker exec postgres-dev psql -U postgres -c "CREATE DATABASE testdb;"
CREATE DATABASE

$ docker stop postgres-dev
postgres-dev

$ docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -v postgres_data:/var/lib/postgresql \
  -d postgres:18

$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
 testdb    | postgres | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           |

{{< /tab >}}

{{< /tabs >}}

If you see testdb in the output, persistence works: The database survived because the volume preserved the data directory.

Managing volumes

List all volumes:

$ docker volume ls --filter name=postgres_data
DRIVER    VOLUME NAME
local     postgres_data

Inspect a volume to see its details:

$ docker volume inspect postgres_data
[
    {
        "CreatedAt": "2025-01-05T10:30:00Z",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/postgres_data/_data",
        "Name": "postgres_data",
        "Options": null,
        "Scope": "local"
    }
]

Remove an unused volume (warning: this deletes all data):

$ docker volume rm postgres_data

Bind mounts (alternative)

Bind mounts map a specific host directory to a container path. Unlike named volumes, you control exactly where data lives on the host filesystem.

Create a directory on your host machine to store Postgres data.

{{< tabs >}} {{< tab name="Using DHIs" >}}

mkdir -p ~/postgres-data && sudo chown -R 999:999 ~/postgres-data

Run Postgres using a bind mount.

docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -v ~/postgres-data:/var/lib/postgresql \
  -d dhi.io/postgres:18

{{< /tab >}}

{{< tab name="Using DOIs" >}}

$ mkdir -p ~/postgres-data

Run Postgres using a bind mount.

$ docker run --rm --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -v ~/postgres-data:/var/lib/postgresql \
  -d postgres:18

{{< /tab >}} {{< /tabs >}}

When to use bind mounts

Bind mounts are useful when you need direct filesystem access to the data directory for backup scripts that read files directly, when integrating with host-level monitoring tools, or when specific permission requirements exist. For most development and production scenarios, named volumes are simpler and less error-prone.

Common bind mount issues

Permission errors are the most frequent problem with bind mounts. PostgreSQL runs as user postgres (UID 999) inside the container. If your host directory has restrictive permissions, the container fails to start.

Check logs if the container exits immediately:

$ docker logs postgres-dev

Docker Compose configuration

Docker Compose captures your entire configuration in a file, making setups reproducible and easier to manage as complexity grows.

Create a compose.yaml file:

services:
  db:
    image: postgres:18
    container_name: postgres-dev
    environment:
      POSTGRES_PASSWORD: mysecretpassword
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql

volumes:
  postgres_data:

Start the database:

$ docker compose up -d

Stop and remove containers (volume persists):

$ docker compose down

Alternatively, you can stop, remove containers, and delete the volume:

$ docker compose down -v

This compose file becomes the foundation for adding initialization scripts, performance tuning, and companion services covered in subsequent guides.

Environment variables reference

The official PostgreSQL image supports these environment variables:

Variable Required Description
POSTGRES_PASSWORD Yes Superuser password
POSTGRES_USER No Superuser name (default: postgres)
POSTGRES_DB No Default database name (default: value of POSTGRES_USER)

Next steps

With persistent storage configured, you're ready to customize PostgreSQL further. The next chapter of the guide covers:

Advanced Configuration and Initialization

With persistent storage configured in the previous section, you're ready to customize PostgreSQL for real-world use. This guide covers advanced configuration techniques for running PostgreSQL in Docker containers, including automated database initialization, performance tuning, and timezone configuration.

Overview

While PostgreSQL containers can be started quickly with default settings, production environments require customized configurations. This guide explains how to:

Initialization scripts

The official PostgreSQL Docker image supports running initialization scripts automatically when the container starts for the first time. Any files placed in the /docker-entrypoint-initdb.d/ directory are executed in alphabetical order.

How initialization works

When the container starts, it checks whether the PostgreSQL data directory is empty. If the directory already contains data, PostgreSQL starts immediately without running any initialization. If the directory is empty, the container runs initdb to create a new database cluster, then executes all scripts in /docker-entrypoint-initdb.d/ in alphabetical order before starting PostgreSQL.

Supported file formats

Format Description
.sql SQL commands executed directly
.sql.gz Gzip-compressed SQL files
.sh Shell scripts executed with bash

[!IMPORTANT]

Initialization scripts only run when the PostgreSQL data directory (/var/lib/postgresql/data) is empty. If you mount a volume containing existing data, initialization is skipped. This behavior prevents overwriting existing databases.

Mounting initialization scripts

Use Docker Compose to mount initialization scripts into the container. First, create a project directory:

$ mkdir -p postgres-project/init-db
$ cd postgres-project

Create a compose.yaml file:

services:
  db:
    image: postgres:18
    volumes:
      - ./init-db:/docker-entrypoint-initdb.d
      - postgres_data:/var/lib/postgresql
    environment:
      POSTGRES_PASSWORD: mysecretpassword

volumes:
  postgres_data:

All scripts in the ./init-db directory execute when the container starts for the first time. This is great for bootstrapping databases.

Initialization script example

Create a file named init.sql in your init-db directory:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

This script runs automatically when the container starts for the first time, creating your initial database schema.

[!NOTE]

Ensure initialization scripts have proper read permissions. If you encounter "Permission denied" errors, run chmod 644 init-db/*.sql to make the files readable by the container.

Performance tuning

Default PostgreSQL settings are conservative to work on systems with limited resources. For production workloads, you should tune these parameters based on your container's allocated resources.

Method 1: Custom configuration file

For complete control, mount a custom postgresql.conf file. First, extract the default configuration:

$ docker run -i --rm postgres:18 cat /usr/share/postgresql/postgresql.conf.sample > my-postgres.conf

Edit my-postgres.conf with your desired settings, then mount it in your Compose file:

services:
  db:
    image: postgres:18
    volumes:
      - ./my-postgres.conf:/etc/postgresql/postgresql.conf
      - ./init-db:/docker-entrypoint-initdb.d
      - postgres_data:/var/lib/postgresql
    command: postgres -c config_file=/etc/postgresql/postgresql.conf
    environment:
      POSTGRES_PASSWORD: mysecretpassword

volumes:
  postgres_data:

Key configuration parameters

The following tables list important postgresql.conf parameters for containerized PostgreSQL deployments.

Connection settings

Parameter Description Default
listen_addresses IP addresses to listen on localhost
port TCP port number 5432
max_connections Maximum concurrent connections 100

Memory settings

Parameter Description Recommended starting value
shared_buffers Shared memory for caching 25% of container memory
work_mem Memory per query operation 4MB - 64MB
maintenance_work_mem Memory for VACUUM, CREATE INDEX 64MB - 256MB
effective_cache_size Planner's cache size estimate 50-75% of container memory
Docker memory limits

When tuning memory parameters, set explicit memory limits on your container using deploy.resources.limits.memory in Compose or --memory with docker run. Without limits, PostgreSQL sees the host's total RAM and may allocate more than intended. For example, if your container should use 4GB maximum, set shared_buffers to approximately 1GB (25%).

I/O settings

Parameter Description Recommended starting value
effective_io_concurrency Concurrent disk I/O operations 200 for SSDs, 2 for HDDs

Timeout settings

Parameter Description Default
statement_timeout Max time for any statement 0 (disabled)
lock_timeout Max time to wait for a lock 0 (disabled)
deadlock_timeout Time before checking for deadlock 1s
transaction_timeout Max time for a transaction 0 (disabled)

[!NOTE]

Setting shared_buffers too high in a container can exceed kernel shared memory limits. Use no more than 25-30% of the container's memory limit.

Timezone and locale configuration

Proper localization ensures timestamps and sorting behave correctly for your application's users.

services:
  db:
    image: postgres:18
    volumes:
      - postgres_data:/var/lib/postgresql
      - /etc/localtime:/etc/localtime:ro
      - /etc/timezone:/etc/timezone:ro
    environment:
      POSTGRES_PASSWORD: mysecretpassword
      TZ: America/New_York

volumes:
  postgres_data:

Alternatively, set the timezone using a PostgreSQL command-line parameter:

services:
  db:
    image: postgres:18
    command: ["postgres", "-c", "timezone=America/New_York"]
    environment:
      POSTGRES_PASSWORD: mysecretpassword

Setting the locale

Specify locale settings during database initialization using the POSTGRES_INITDB_ARGS environment variable:

services:
  db:
    image: postgres:18
    volumes:
      - postgres_data:/var/lib/postgresql
    environment:
      POSTGRES_PASSWORD: mysecretpassword
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=en_US.UTF-8 --lc-ctype=en_US.UTF-8"

volumes:
  postgres_data:

This affects collation (sorting) and character processing behavior. Changing this variable after database creation has no effect—it only applies during the first run when the data directory is initialized.

Connecting to the database

You can interact with PostgreSQL running in a container even without psql installed on your host machine.

Interactive shell

Open a psql session inside the container:

$ docker exec -it postgres-container psql -U postgres

Connect to a specific database:

$ docker exec -it postgres-container psql -U postgres -d mydb

Networking and connectivity

This guide covers two common ways to connect to PostgreSQL running in Docker:

Prerequisite: This guide assumes you have PostgreSQL running with persistent storage. If you don't, follow the Immediate Setup & Data Persistence guide first.

Internal network access (container-to-container)

When your application runs in another container, connecting to PostgreSQL through a user-defined bridge network is the recommended approach. This setup provides automatic DNS resolution, so your application can connect to PostgreSQL using the container name as the hostname, without needing to track IP addresses.

[!NOTE] Why not use the default bridge network? While containers on the default bridge network can communicate, they can only do so by IP address. Since container IP addresses change when containers restart, this would require updating your PostgreSQL connection strings each time. User-defined bridge networks solve this by providing automatic DNS resolution, ensuring your PostgreSQL connection strings remain stable even if containers restart and receive new IP addresses.

Here's a quick comparison:

[!NOTE]

The following examples show the difference in approach. To actually test this, follow the steps in this guide to set up containers on the appropriate networks first.

With the default bridge network, you'd need to find the IP address first:

# Get the container's IP address (changes on restart)
docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' postgres-dev
# Output: 172.17.0.2

# Then connect using that IP address from another container
# (No --network flag needed - containers default to bridge network)
docker run --rm -it \
  -e PGPASSWORD=mysecretpassword \
  postgres:18 \
  psql -h 172.17.0.2 -U postgres

With a user-defined network, you simply use the container name:

# Container name works directly - no IP lookup needed
docker run --rm -it \
  --network my-app-net \
  -e PGPASSWORD=mysecretpassword \
  postgres:18 \
  psql -h postgres-dev -U postgres

Step 1: Create a user-defined network

docker network create my-app-net

# Example Output
ab7f984be43a0ca15534a9ee568716ddbe869a5875077fad3ef3192e3af7d288

docker network ls
# Output
ab7f984be43a   my-app-net    bridge    local


Step 2: Run PostgreSQL on that network (no port publishing)

Notice there is no -p 5432:5432 here. This keeps PostgreSQL internal to Docker and not accessible from the host machine, which is more secure for production environments.

docker run -d --name postgres-dev \
  --network my-app-net \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -v postgres_data:/var/lib/postgresql \
  postgres:18

  # Output
CONTAINER ID  IMAGE        COMMAND                 CREATED         STATUS        PORTS     NAMES
6d351ed89efc  postgres:18  "docker-entrypoint.s…"  9 seconds ago   Up 8 seconds  5432/tcp  postgres-dev

Step 3: Connect from another container using the Postgres container name

You can test connectivity with a temporary psql client container:

docker run --rm -it \
  --network my-app-net \
  -e PGPASSWORD=mysecretpassword \
  postgres:18 \
  psql -h postgres-dev -U postgres

Key point: -h postgres-dev works because Docker DNS resolves the container name on a user-defined network. The container name acts as the hostname.

Connection string examples

When connecting from your application container, use these PostgreSQL connection strings:

[!NOTE]

The default port 5432 is used in these examples. If you're connecting to a different PostgreSQL instance or have changed the port, update the connection string accordingly. The container name (postgres-dev) is resolved by Docker DNS to the container's IP address on the network.

Connecting from the host (external access)

To connect to PostgreSQL from your host machine using tools like psql, pgAdmin, DBeaver, or database management scripts, you need to publish PostgreSQL's port (5432) to the host. This allows external tools to reach the PostgreSQL container.

Expose Postgres to localhost only (recommended for development)

This binds to 127.0.0.1 so it's only reachable from your local machine, not from other devices on your network. This is the most secure option for development.

docker run -d --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 127.0.0.1:5432:5432 \
  -v postgres_data:/var/lib/postgresql \
  postgres:18

Now connect from your host:

If you have psql installed on your host:

psql -h localhost -p 5432 -U postgres

You'll be prompted for the password. Alternatively, you can use the PGPASSWORD environment variable:

PGPASSWORD=mysecretpassword psql -h localhost -p 5432 -U postgres

Connecting with PostgreSQL GUI tools

All tools will prompt for the password you set with POSTGRES_PASSWORD.

Expose Postgres to all network interfaces (use with caution)

To allow connections from other devices on your network, use -p 5432:5432 instead of -p 127.0.0.1:5432:5432. This binds PostgreSQL to all network interfaces on your host, making it accessible from any device that can reach your host, not just localhost.

docker run -d --name postgres-dev \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  -v postgres_data:/var/lib/postgresql \
  postgres:18

[!WARNING]

Exposing PostgreSQL to all network interfaces (0.0.0.0:5432) makes it accessible from any device that can reach your host. Only use this in trusted network environments or behind a firewall. For production, consider using a reverse proxy or VPN instead.

PostgreSQL security considerations for external access

When exposing PostgreSQL to external access, follow these PostgreSQL-specific security practices:

The Advanced configuration and initialization guide shows how to use initialization scripts to create users and roles automatically.

Using Docker Compose for networking

Docker Compose automatically creates a network for your services, making networking configuration simpler. Here's an example that combines both internal and external access:

services:
  db:
    image: postgres:18
    container_name: postgres-dev
    environment:
      POSTGRES_PASSWORD: mysecretpassword
    volumes:
      - postgres_data:/var/lib/postgresql
    ports:
      - "127.0.0.1:5432:5432"  # Expose to localhost only
    networks:
      - app-network

  app:
    build: ./my-app
    environment:
      DATABASE_URL: postgresql://postgres:mysecretpassword@db:5432/mydb
    networks:
      - app-network
    depends_on:
      - db

volumes:
  postgres_data:

networks:
  app-network:
    driver: bridge

In this PostgreSQL-focused setup:

PostgreSQL connection details for the app service:

[!NOTE]

Docker Compose automatically creates a network for your project. Services can reach each other by service name without explicit network configuration, but defining a custom network gives you more control. For PostgreSQL, this means your application can always connect using the service name, regardless of container restarts or IP changes.

Troubleshooting

This section covers common PostgreSQL connection issues and their solutions when working with Docker networking.

"Could not translate host name postgres-dev"

"Connection refused" or "could not connect to server"

"Password authentication failed" or "FATAL: password authentication failed for user"

"Network not found"

Companions for PostgreSQL

PostgreSQL ecosystem companions: pgAdmin, PgBouncer, and performance testing

Running a standalone PostgreSQL container is often just the beginning. What happens when thousands of connections arrive, or when you need a visual interface to manage your database?

This is where companion tools come into play. These applications extend PostgreSQL with capabilities the core database engine doesn't provide natively: visual administration, connection pooling, and performance benchmarking. This guide covers how to deploy pgAdmin 4, PgBouncer, Pgpool-II, and pgbench in Docker, when to use each tool, and real-world benchmark results demonstrating their performance impact.

pgAdmin 4: Visual management platform

pgAdmin 4 is the industry-standard open source management tool for PostgreSQL. When deployed in Docker, it typically runs in Server Mode, providing a multi-user web interface to manage one or more database instances.

While you can accomplish everything from the command line using psql, a visual interface significantly simplifies writing complex queries, visualizing table structures, and exploring database objects.

Key considerations

When running pgAdmin in Docker, keep these points in mind:

Docker Compose configuration

To quickly deploy pgAdmin:

pgadmin:
  image: dpage/pgadmin4:8.14
  environment:
    PGADMIN_DEFAULT_EMAIL: admin@example.com
    PGADMIN_DEFAULT_PASSWORD: secure_password
  volumes:
    - pgadmin_data:/var/lib/pgadmin
  ports:
    - "8080:80"

With this configuration, access the pgAdmin interface at http://localhost:8080. Use the email and password specified in the environment variables for initial sign in.

[!IMPORTANT]

In production environments, pass PGADMIN_DEFAULT_PASSWORD as an external environment variable or use Docker secrets. Storing passwords in plain text within docker-compose.yml poses a security risk.

Now that you have visual database management in place, the next challenge in production environments is handling connection load. The following section explains how to manage high-volume database traffic.

PgBouncer: Lightweight connection pooling

PostgreSQL creates a new process for every client connection, which consumes significant RAM. What happens when you have 1,000 concurrent users? PgBouncer solves exactly this problem.

PgBouncer is a lightweight proxy that pools connections, allowing thousands of applications to share a small number of actual database backends. Think of it as a traffic controller: everyone wants to pass through simultaneously, but the controller regulates the flow to prevent congestion.

Pooling modes

PgBouncer offers three distinct pooling modes:

Mode Description Use case
Session Connection assigned for entire session duration Long-lived connections, session variables
Transaction Connection returned after each transaction ends Web applications, microservices (most common)
Statement Connection returned after every SQL statement Simple queries, no multi-statement transactions

When to use PgBouncer

PgBouncer becomes essential when you encounter:

Complete Docker Compose setup

To run PostgreSQL and PgBouncer together, you need three files: docker-compose.yml, pgbouncer.ini, and userlist.txt.

First, create the PgBouncer configuration file (pgbouncer.ini):

[databases]
benchmark = host=postgres port=5432 dbname=benchmark user=postgres

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = trust
auth_file = /etc/pgbouncer/userlist.txt
admin_users = postgres
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 50
min_pool_size = 10
reserve_pool_size = 10
max_db_connections = 100

Next, create the user authentication file (userlist.txt):

"postgres" "postgres"

Finally, create the Docker Compose file (docker-compose.yml):

services:
  postgres:
    image: postgres:18
    container_name: postgres
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: benchmark
      POSTGRES_HOST_AUTH_METHOD: trust
    volumes:
      - postgres_data:/var/lib/postgresql
    ports:
      - "5432:5432"
    networks:
      - pgnet
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  pgbouncer:
    image: percona/percona-pgbouncer:1.25.0
    container_name: pgbouncer
    volumes:
      - ./pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini
      - ./userlist.txt:/etc/pgbouncer/userlist.txt
    ports:
      - "6432:6432"
    networks:
      - pgnet
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:

networks:
  pgnet:
    driver: bridge

Key configuration notes:

[!NOTE]

The Percona PgBouncer entrypoint script processes the configuration files on startup. Mount them without the read-only flag to avoid permission errors.

pgbench: Performance benchmarking

pgbench is a benchmarking utility included with the official PostgreSQL image. It allows you to simulate heavy workloads and verify how your Docker configuration performs under pressure.

Initialize benchmark tables

First, create the test tables. The -s (scale) parameter determines data size—scale factor 50 creates approximately 5 million rows:

docker exec postgres pgbench -i -s 50 -U postgres benchmark

Run stress tests

Key parameters:

Test with direct PostgreSQL connection:

docker exec postgres pgbench -h localhost -U postgres -c 50 -j 4 -T 60 benchmark

Test through PgBouncer:

docker exec postgres pgbench -h pgbouncer -p 6432 -U postgres -c 50 -j 4 -T 60 benchmark

Understanding benchmark results

Does PgBouncer actually make a difference? Run the benchmarks yourself to find out. Your results will vary based on your hardware, Docker configuration, network setup, and system load.

What to expect

When you run these benchmarks, you'll observe patterns rather than specific numbers. Think of it like comparing two different routes to work: the "faster" route depends on traffic conditions, time of day, and your vehicle.

Key observations

When comparing direct connections versus PgBouncer, you'll typically notice:

1. Connection overhead differs significantly

Direct connections require PostgreSQL to spawn a new process for each client. PgBouncer reuses existing connections. Watch the "initial connection time" metric in your results—PgBouncer often shows dramatically faster connection setup.

2. Behavior under pressure reveals the real difference

Try increasing the client count (-c parameter) gradually: 50, 100, 150, 200. At some point, direct connections will fail with "too many clients already" while PgBouncer continues handling requests. This is PgBouncer's primary value: it prevents connection exhaustion.

3. Throughput varies by environment

On some systems, direct connections show higher transactions per second (TPS) at low concurrency. On others, PgBouncer wins even with few clients. The difference depends on:

Pre-seeding database with schema and data at startup for development environment

Pre-seeding databases with essential data and schema during local development is a common practice to enhance the development and testing workflow. By simulating real-world scenarios, this practice helps catch frontend issues early, ensures alignment between Database Administrators and Software Engineers, and facilitates smoother collaboration. Pre-seeding offers benefits like confident deployments, consistency across environments, and early issue detection, ultimately improving the overall development process.

In this guide, you will learn how to:

Using Postgres with Docker

The official Docker image for Postgres provides a convenient way to run Postgres database on your development machine. A Postgres Docker image is a pre-configured environment that encapsulates the PostgreSQL database system. It's a self-contained unit, ready to run in a Docker container. By using this image, you can quickly and easily set up a Postgres instance without the need for manual configuration.

Prerequisites

The following prerequisites are required to follow along with this how-to guide:

Launching Postgres

Launch a quick demo of Postgres by using the following steps:

  1. Open the terminal and run the following command to start a Postgres container.

    This example will launch a Postgres container, expose port 5432 onto the host to let a native-running application to connect to it with the password mysecretpassword.

    $ docker run -d --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=mysecretpassword postgres
    
  2. Verify that Postgres is up and running by selecting the container and checking the logs on Docker Dashboard.

    PostgreSQL Database directory appears to contain a database; Skipping initialization
    
    2024-09-08 09:09:47.136 UTC [1] LOG:  starting PostgreSQL 16.4 (Debian 16.4-1.pgdg120+1) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
    2024-09-08 09:09:47.137 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5432
    2024-09-08 09:09:47.137 UTC [1] LOG:  listening on IPv6 address "::", port 5432
    2024-09-08 09:09:47.139 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
    2024-09-08 09:09:47.142 UTC [29] LOG:  database system was shut down at 2024-09-08 09:07:09 UTC
    2024-09-08 09:09:47.148 UTC [1] LOG:  database system is ready to accept connections
    
  3. Connect to Postgres from the local system.

    The psql is the PostgreSQL interactive shell that is used to connect to a Postgres database and let you start executing SQL commands. Assuming that you already have psql utility installed on your local system, it's time to connect to the Postgres database. Run the following command on your local terminal:

    $ docker exec -it postgres psql -h localhost -U postgres
    

    You can now execute any SQL queries or commands you need within the psql prompt.

    Use \q or \quit to exit from the Postgres interactive shell.

Pre-seed the Postgres database using a SQL script

Now that you've familiarized yourself with Postgres, it's time to see how to pre-seed it with sample data. In this demonstration, you'll first create a script that holds SQL commands. The script defines the database, and table structure and inserts sample data. Then you will connect the database to verify the data.

Assuming that you have an existing Postgres database instance up and running, follow these steps to seed the database.

  1. Create an empty file named seed.sql and add the following content.

    CREATE DATABASE sampledb;
    
    \c sampledb
    
    CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      name VARCHAR(50),
      email VARCHAR(100) UNIQUE
    );
    
    INSERT INTO users (name, email) VALUES
      ('Alpha', 'alpha@example.com'),
      ('Beta', 'beta@example.com'),
      ('Gamma', 'gamma@example.com');  
    

    The SQL script creates a new database called sampledb, connects to it, and creates a users table. The table includes an auto-incrementing id as the primary key, a name field with a maximum length of 50 characters, and a unique email field with up to 100 characters.

    After creating the table, the INSERT command inserts three users into the users table with their respective names and emails. This setup forms a basic database structure to store user information with unique email addresses.

  2. Seed the database.

    It’s time to feed the content of the seed.sql directly into the database by using the < operator. The command is used to execute a SQL script named seed.sql against a Postgres database named sampledb.

    $ cat seed.sql | docker exec -i postgres psql -h localhost -U postgres -f-
    

    Once the query is executed, you will see the following results:

    CREATE DATABASE
    You are now connected to database "sampledb" as user "postgres".
    CREATE TABLE
    INSERT 0 3
    
  3. Run the following psql command to verify if the table named users is populated in the database sampledb or not.

    $ docker exec -it postgres psql -h localhost -U postgres sampledb
    

    You can now run \l in the psql shell to list all the databases on the Postgres server.

    sampledb=# \l
                                                 List of databases
    Name    |  Owner   | Encoding |  Collate   |   Ctype    | ICU Locale | Locale Provider |   Access privileges
    -----------+----------+----------+------------+------------+------------+-----------------+--------------------
    postgres  | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            |
    sampledb  | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            |
    template0 | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            | =c/postgres          +
              |          |          |            |            |            |                 | postgres=CTc/postgres
    template1 | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            | =c/postgres          +
              |          |          |            |            |            |                 | postgres=CTc/postgres
    (4 rows)
    

    To retrieve all the data from the users table, enter the following query:

    sampledb=# SELECT * FROM users;
    id | name  |       email
    ----+-------+----------------
     1 | Alpha | alpha@example.com
     2 | Beta  | beta@example.com
     3 | Gamma | gamma@example.com
    (3 rows)
    

    Use \q or \quit to exit from the Postgres interactive shell.

Pre-seed the database by bind-mounting a SQL script

In Docker, mounting refers to making files or directories from the host system accessible within a container. This let you to share data or configuration files between the host and the container, enabling greater flexibility and persistence.

Now that you have learned how to launch Postgres and pre-seed the database using an SQL script, it’s time to learn how to mount an SQL file directly into the Postgres containers’ initialization directory (/docker-entrypoint-initdb.d). The /docker-entrypoint-initdb.d is a special directory in PostgreSQL Docker containers that is used for initializing the database when the container is first started

Make sure you stop any running Postgres containers (along with volumes) to prevent port conflicts before you follow the steps:

$ docker container stop postgres
  1. Modify the seed.sql with the following entries:

    CREATE TABLE IF NOT EXISTS users (
     id SERIAL PRIMARY KEY,
     name VARCHAR(50),
     email VARCHAR(100) UNIQUE
    );
    
    INSERT INTO users (name, email) VALUES
     ('Alpha', 'alpha@example.com'),
     ('Beta', 'beta@example.com'),
     ('Gamma', 'gamma@example.com')
    ON CONFLICT (email) DO NOTHING;
    
  2. Create a text file named Dockerfile and copy the following content.

    # syntax=docker/dockerfile:1
    FROM postgres:18
    COPY seed.sql /docker-entrypoint-initdb.d/
    

    This Dockerfile copies the seed.sql script directly into the PostgreSQL container's initialization directory.

  3. Use Docker Compose.

    Using Docker Compose makes it even easier to manage and deploy the PostgreSQL container with the seeded database. This compose.yml file defines a Postgres service named db using the latest Postgres image, which sets up a database with the name sampledb, along with a user postgres and a password mysecretpassword.

    services:
      db:
        build:
          context: .
          dockerfile: Dockerfile
        container_name: my_postgres_db
        environment:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: mysecretpassword
          POSTGRES_DB: sampledb
        ports:
          - "5432:5432"
        volumes:
          - data_sql:/var/lib/postgresql   # Persistent data storage
    
    volumes:
      data_sql:
    

    It maps port 5432 on the host to the container's 5432, letting you access the Postgres database from outside the container. It also defines data_sql for persisting the database data, ensuring that data is not lost when the container is stopped.

    It is important to note that the port mapping to the host is only necessary if you want to connect to the database from non-containerized programs. If you containerize the service that connects to the DB, you should connect to the database over a custom bridge network.

  4. Bring up the Compose service.

    Assuming that you've placed the seed.sql file in the same directory as the Dockerfile, execute the following command:

    $ docker compose up -d --build
    
  5. It’s time to verify if the table users get populated with the data.

    $ docker exec -it my_postgres_db psql -h localhost -U postgres sampledb
    
    sampledb=# SELECT * FROM users;
      id | name  |       email
    ----+-------+----------------
       1 | Alpha | alpha@example.com
       2 | Beta  | beta@example.com
       3 | Gamma | gamma@example.com
     (3 rows)
    
    sampledb=#
    

Pre-seed the database using JavaScript code

Now that you have learned how to seed the database using various methods like SQL script, mounting volumes etc., it's time to try to achieve it using JavaScript code.

  1. Create a .env file with the following:

    POSTGRES_USER=postgres
    POSTGRES_DB_HOST=localhost
    POSTGRES_DB=sampledb
    POSTGRES_PASSWORD=mysecretpassword
    POSTGRES_PORT=5432
    
  2. Create a new JavaScript file called seed.js with the following content:

    The following JavaScript code imports the dotenv package which is used to load environment variables from an .env file. The .config() method reads the .env file and sets the environment variables as properties of the process.env object. This let you to securely store sensitive information like database credentials outside of your code.

    Then, it creates a new Pool instance from the pg library, which provides a connection pool for efficient database interactions. The seedData function is defined to perform the database seeding operations. It is called at the end of the script to initiate the seeding process. The try...catch...finally block is used for error handling.

    require('dotenv').config();  // Load environment variables from .env file
    const { Pool } = require('pg');
    
    // Create a new pool using environment variables
    const pool = new Pool({
      user: process.env.POSTGRES_USER,
      host: process.env.POSTGRES_DB_HOST,
      database: process.env.POSTGRES_DB,
      port: process.env.POSTGRES_PORT,
      password: process.env.POSTGRES_PASSWORD,
    });
    
    const seedData = async () => {
      try {
         // Drop the table if it already exists (optional)
         await pool.query(`DROP TABLE IF EXISTS todos;`);
    
         // Create the table with the correct structure
         await pool.query(`
           CREATE TABLE todos (
             id SERIAL PRIMARY KEY,
             task VARCHAR(255) NOT NULL,
             completed BOOLEAN DEFAULT false
               );
         `   );
    
         // Insert seed data
         await pool.query(`
           INSERT INTO todos (task, completed) VALUES
           ('Watch netflix', false),
           ('Finish podcast', false),
           ('Pick up kid', false);
           `);
           console.log('Database seeded successfully!');
         } catch (err) {
           console.error('Error seeding the database', err);
         } finally {
           pool.end();
        }
      };
    
      // Call the seedData function to run the script
      seedData();
    
  3. Kick off the seeding process

    $ node seed.js
    

    You should see the following command:

    Database seeded successfully!
    
  4. Verify if the database is seeded correctly:

    $ docker exec -it postgres psql -h localhost -U postgres sampledb
    
    sampledb=# SELECT * FROM todos;
    id |      task      | completed
    ----+----------------+--------
    1 | Watch netflix  | f
    2 | Finish podcast | f
    3 | Pick up kid    | f
    (3 rows)  
    

Recap

Pre-seeding a database with schema and data at startup is essential for creating a consistent and realistic testing environment, which helps in identifying issues early in development and aligning frontend and backend work. This guide has equipped you with the knowledge and practical steps to achieve pre-seeding using various methods, including SQL script, Docker integration, and JavaScript code.

Python language-specific guide

Acknowledgment

This guide is a community contribution. Docker would like to thank Esteban Maya and Igor Aleksandrov for their contribution to this guide.

The Python language-specific guide teaches you how to containerize a Python application using Docker. In this guide, you’ll learn how to:

Start by containerizing an existing Python application.

Containerize a Python application

Prerequisites

Overview

Containerizing your application means packaging it together with its dependencies, configuration, and runtime into a single portable unit called a container image. Running that image creates a container, an isolated process that behaves the same on any machine, whether it's your laptop, a CI runner, or a production server.

In this section, you'll containerize a simple FastAPI web application. You'll write a Dockerfile that describes how to build the image, add a compose.yaml file that defines how Docker runs your container, and then build and start the application with one command.

You'll use Docker Hardened Images as the base. These are minimal, secure Python images maintained by Docker.

Create the application

The sample application is a minimal FastAPI service with a single endpoint that returns a JSON greeting. Create the following files in a new python-docker-example directory. To create all the files at once, switch to the Scaffold script tab in the file browser and copy the shell command.

{{< files name="python-docker-example" >}}

{{< file path="app.py" status="new" >}}

# A minimal FastAPI application.
# The root endpoint (GET /) returns a JSON "Hello World" response.
# See https://fastapi.tiangolo.com/ for the framework reference.

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

{{< /file >}}

{{< file path="requirements.txt" status="new" >}}

# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/

fastapi==0.115.12
uvicorn==0.34.3

{{< /file >}}

{{< file path=".gitignore" status="new" >}}

# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Secrets
db/password.txt

{{< /file >}}

{{< /files >}}

If you already have Python installed and want to verify the app works before containerizing it, you can run it locally:

$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install -r requirements.txt
$ uvicorn app:app --reload

[!NOTE]

On Windows, activate the virtual environment with .venv\Scripts\activate instead of source .venv/bin/activate.

If you don't have Python installed, skip ahead to the next section. The remaining steps run the application in a container, with no local Python required.

Create the Docker assets

Sign in to the DHI registry so Docker can pull the Python base images during the build. The available Python images are listed in the catalog.

$ docker login dhi.io

Add the following three files to your python-docker-example directory. The Dockerfile describes how to build the image, compose.yaml defines how Docker runs the container, and .dockerignore keeps unwanted files out of the build context.

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

{{< files name="python-docker-example" >}}

{{< file path="app.py" >}}

# A minimal FastAPI application.
# The root endpoint (GET /) returns a JSON "Hello World" response.
# See https://fastapi.tiangolo.com/ for the framework reference.

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

{{< /file >}}

{{< file path="requirements.txt" >}}

# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/

fastapi==0.115.12
uvicorn==0.34.3

{{< /file >}}

{{< file path="Dockerfile" status="new" >}}

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/

# Use the dev image to build and install dependencies.
FROM dhi.io/python:3.12-dev AS builder

WORKDIR /app

RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them into
# this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
    --mount=type=bind,source=requirements.txt,target=requirements.txt \
    pip install -r requirements.txt

# Use the minimal runtime image. It runs as nonroot by default.
FROM dhi.io/python:3.12

WORKDIR /app

COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"

# Copy the source code into the container.
COPY . .

# Expose the port that the application listens on.
EXPOSE 8000

# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]

{{< /file >}}

{{< file path="compose.yaml" status="new" >}}

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  server:
    build:
      context: .
    ports:
      - 8000:8000

{{< /file >}}

{{< file path=".dockerignore" status="new" >}}

# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

{{< /file >}}

{{< file path=".gitignore" >}}

# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Secrets
db/password.txt

{{< /file >}}

{{< /files >}}

To learn more about each file, see the following:

Run the application

Inside the python-docker-example directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:8000. You should see a simple FastAPI application.

In the terminal, press ctrl+c to stop the application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the python-docker-example directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:8000.

To see the OpenAPI docs you can go to http://localhost:8000/docs.

You should see a simple FastAPI application.

In the terminal, run the following command to stop the application.

$ docker compose down

For more information about Compose commands, see the Compose CLI reference.

Use containers for Python development

Prerequisites

Complete Containerize a Python application.

Overview

Once your application runs in a container, the next step is making the container loop part of your everyday development workflow. Code changes should show up quickly, and services your app depends on, like databases, should run right alongside it.

In this section, you'll extend the project from the previous topic by adding a PostgreSQL database service to your compose.yaml, persisting the database data in a named volume, and enabling Compose Watch so that changes you save in your editor are picked up by the running container without a manual rebuild.

Update the application

You'll update your application to connect to a PostgreSQL database. Continue working in your python-docker-example directory.

Replace app.py and requirements.txt, and add a new config.py file with the following contents.

[!NOTE]

The application won't run yet after this step. It tries to connect to a PostgreSQL database that doesn't exist. The next two sections add the database service and the Docker configuration needed to run everything together.

{{< files name="python-docker-example" >}}

{{< file path="app.py" status="modified" >}}

# FastAPI application backed by a PostgreSQL database via SQLModel.
# The FastAPI lifespan handler creates database tables at startup.
# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list).
# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/

from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select

from config import settings


class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    secret_name: str
    age: int | None = Field(default=None, index=True)


engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))


def create_db_and_tables() -> None:
    SQLModel.metadata.create_all(engine)


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
    create_db_and_tables()
    yield


app = FastAPI(lifespan=lifespan)


@app.get("/")
def hello() -> str:
    return "Hello, Docker!"


@app.post("/heroes/")
def create_hero(hero: Hero) -> Hero:
    with Session(engine) as session:
        session.add(hero)
        session.commit()
        session.refresh(hero)
        return hero


@app.get("/heroes/")
def read_heroes() -> Sequence[Hero]:
    with Session(engine) as session:
        heroes = session.exec(select(Hero)).all()
        return heroes

{{< /file >}}

{{< file path="config.py" status="new" >}}

# Pydantic settings that read PostgreSQL connection details from the
# environment. Supports a password file (Docker secrets) via
# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD.
# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/

import os
from typing import Any

from pydantic import (
    PostgresDsn,
    computed_field,
    field_validator,
    model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    POSTGRES_SERVER: str
    POSTGRES_PORT: int = 5432
    POSTGRES_USER: str
    POSTGRES_PASSWORD: str | None = None
    POSTGRES_PASSWORD_FILE: str | None = None
    POSTGRES_DB: str

    @model_validator(mode="before")
    @classmethod
    def check_postgres_password(cls, data: Any) -> Any:
        """Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set."""
        if isinstance(data, dict):
            password_file: str | None = data.get("POSTGRES_PASSWORD_FILE")  # type: ignore
            password: str | None = data.get("POSTGRES_PASSWORD")  # type: ignore
            if password_file is None and password is None:
                raise ValueError(
                    "At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set."
                )
        return data  # type: ignore

    @field_validator("POSTGRES_PASSWORD_FILE", mode="before")
    @classmethod
    def read_password_from_file(cls, v: str | None) -> str | None:
        if v is not None:
            file_path = v
            if os.path.exists(file_path):
                with open(file_path) as file:
                    return file.read().strip()
            raise ValueError(f"Password file {file_path} does not exist.")
        return v

    @computed_field
    @property
    def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
        url = MultiHostUrl.build(
            scheme="postgresql+psycopg",
            username=self.POSTGRES_USER,
            password=self.POSTGRES_PASSWORD
            if self.POSTGRES_PASSWORD
            else self.POSTGRES_PASSWORD_FILE,
            host=self.POSTGRES_SERVER,
            port=self.POSTGRES_PORT,
            path=self.POSTGRES_DB,
        )
        return PostgresDsn(url)


settings = Settings()  # type: ignore

{{< /file >}}

{{< file path="requirements.txt" status="modified" hl_lines="5-7" >}}

# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/

fastapi==0.115.12
sqlmodel==0.0.24
psycopg[binary]==3.2.9
pydantic-settings==2.9.1
uvicorn==0.34.3

{{< /file >}}

{{< file path="Dockerfile" >}}

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/

# Use the dev image to build and install dependencies.
FROM dhi.io/python:3.12-dev AS builder

WORKDIR /app

RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them into
# this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
    --mount=type=bind,source=requirements.txt,target=requirements.txt \
    pip install -r requirements.txt

# Use the minimal runtime image. It runs as nonroot by default.
FROM dhi.io/python:3.12

WORKDIR /app

COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"

# Copy the source code into the container.
COPY . .

# Expose the port that the application listens on.
EXPOSE 8000

# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]

{{< /file >}}

{{< file path="compose.yaml" >}}

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  server:
    build:
      context: .
    ports:
      - 8000:8000

{{< /file >}}

{{< file path=".dockerignore" >}}

# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

{{< /file >}}

{{< file path=".gitignore" >}}

# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Secrets
db/password.txt

{{< /file >}}

{{< /files >}}

Update Docker assets

Replace Dockerfile and compose.yaml with the following.

{{< files name="python-docker-example" >}}

{{< file path="app.py" >}}

# FastAPI application backed by a PostgreSQL database via SQLModel.
# The FastAPI lifespan handler creates database tables at startup.
# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list).
# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/

from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select

from config import settings


class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    secret_name: str
    age: int | None = Field(default=None, index=True)


engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))


def create_db_and_tables() -> None:
    SQLModel.metadata.create_all(engine)


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
    create_db_and_tables()
    yield


app = FastAPI(lifespan=lifespan)


@app.get("/")
def hello() -> str:
    return "Hello, Docker!"


@app.post("/heroes/")
def create_hero(hero: Hero) -> Hero:
    with Session(engine) as session:
        session.add(hero)
        session.commit()
        session.refresh(hero)
        return hero


@app.get("/heroes/")
def read_heroes() -> Sequence[Hero]:
    with Session(engine) as session:
        heroes = session.exec(select(Hero)).all()
        return heroes

{{< /file >}}

{{< file path="config.py" >}}

# Pydantic settings that read PostgreSQL connection details from the
# environment. Supports a password file (Docker secrets) via
# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD.
# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/

import os
from typing import Any

from pydantic import (
    PostgresDsn,
    computed_field,
    field_validator,
    model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    POSTGRES_SERVER: str
    POSTGRES_PORT: int = 5432
    POSTGRES_USER: str
    POSTGRES_PASSWORD: str | None = None
    POSTGRES_PASSWORD_FILE: str | None = None
    POSTGRES_DB: str

    @model_validator(mode="before")
    @classmethod
    def check_postgres_password(cls, data: Any) -> Any:
        """Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set."""
        if isinstance(data, dict):
            password_file: str | None = data.get("POSTGRES_PASSWORD_FILE")  # type: ignore
            password: str | None = data.get("POSTGRES_PASSWORD")  # type: ignore
            if password_file is None and password is None:
                raise ValueError(
                    "At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set."
                )
        return data  # type: ignore

    @field_validator("POSTGRES_PASSWORD_FILE", mode="before")
    @classmethod
    def read_password_from_file(cls, v: str | None) -> str | None:
        if v is not None:
            file_path = v
            if os.path.exists(file_path):
                with open(file_path) as file:
                    return file.read().strip()
            raise ValueError(f"Password file {file_path} does not exist.")
        return v

    @computed_field
    @property
    def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
        url = MultiHostUrl.build(
            scheme="postgresql+psycopg",
            username=self.POSTGRES_USER,
            password=self.POSTGRES_PASSWORD
            if self.POSTGRES_PASSWORD
            else self.POSTGRES_PASSWORD_FILE,
            host=self.POSTGRES_SERVER,
            port=self.POSTGRES_PORT,
            path=self.POSTGRES_DB,
        )
        return PostgresDsn(url)


settings = Settings()  # type: ignore

{{< /file >}}

{{< file path="requirements.txt" >}}

# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/

fastapi==0.115.12
sqlmodel==0.0.24
psycopg[binary]==3.2.9
pydantic-settings==2.9.1
uvicorn==0.34.3

{{< /file >}}

{{< file path="Dockerfile" status="modified" hl_lines="11,27-34,37,45" >}}

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/

# Use the dev image to build and install dependencies.
# The builder stage is also used directly in development (see compose.yaml).
FROM dhi.io/python:3.12-dev AS builder

WORKDIR /app

RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them
# into this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
    --mount=type=bind,source=requirements.txt,target=requirements.txt \
    pip install -r requirements.txt

# Copy the source code into the container.
COPY . .

# Expose the port that the application listens on.
EXPOSE 8000

# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]


# Use the minimal runtime image for production. It runs as nonroot by default.
FROM dhi.io/python:3.12

WORKDIR /app

COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"

COPY --from=builder /app .

EXPOSE 8000

CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]

{{< /file >}}

{{< file path="compose.yaml" status="modified" hl_lines="8" >}}

services:
  # Application service. The `target: builder` line builds the development
  # image (includes a shell and tools); the production stage of the
  # Dockerfile is unused in development.
  server:
    build:
      context: .
      target: builder
    ports:
      - 8000:8000

{{< /file >}}

{{< file path=".dockerignore" >}}

# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

{{< /file >}}

{{< file path=".gitignore" >}}

# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Secrets
db/password.txt

{{< /file >}}

{{< /files >}}

About these changes

The Dockerfile builder stage now includes COPY . . and a CMD instruction, which makes it directly runnable. This lets Compose target the builder stage during development without rebuilding the production stage. The production stage at the bottom is unchanged and still produces a minimal, nonroot runtime image for shipping.

In compose.yaml, the new target: builder line tells Compose to build and run the builder stage of the Dockerfile during development. Unlike the minimal production image, the development image includes a shell and additional tools that make debugging easier. If you need a shell in a running production container, use Docker Debug instead.

Add a local database and persist data

You can use containers to set up local services, like a database. In this section, you'll update the compose.yaml file to define a database service and a volume to persist data, and add a db/password.txt file that holds the database password.

{{< files name="python-docker-example" >}}

{{< file path="app.py" >}}

# FastAPI application backed by a PostgreSQL database via SQLModel.
# The FastAPI lifespan handler creates database tables at startup.
# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list).
# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/

from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select

from config import settings


class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    secret_name: str
    age: int | None = Field(default=None, index=True)


engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))


def create_db_and_tables() -> None:
    SQLModel.metadata.create_all(engine)


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
    create_db_and_tables()
    yield


app = FastAPI(lifespan=lifespan)


@app.get("/")
def hello() -> str:
    return "Hello, Docker!"


@app.post("/heroes/")
def create_hero(hero: Hero) -> Hero:
    with Session(engine) as session:
        session.add(hero)
        session.commit()
        session.refresh(hero)
        return hero


@app.get("/heroes/")
def read_heroes() -> Sequence[Hero]:
    with Session(engine) as session:
        heroes = session.exec(select(Hero)).all()
        return heroes

{{< /file >}}

{{< file path="config.py" >}}

# Pydantic settings that read PostgreSQL connection details from the
# environment. Supports a password file (Docker secrets) via
# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD.
# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/

import os
from typing import Any

from pydantic import (
    PostgresDsn,
    computed_field,
    field_validator,
    model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    POSTGRES_SERVER: str
    POSTGRES_PORT: int = 5432
    POSTGRES_USER: str
    POSTGRES_PASSWORD: str | None = None
    POSTGRES_PASSWORD_FILE: str | None = None
    POSTGRES_DB: str

    @model_validator(mode="before")
    @classmethod
    def check_postgres_password(cls, data: Any) -> Any:
        """Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set."""
        if isinstance(data, dict):
            password_file: str | None = data.get("POSTGRES_PASSWORD_FILE")  # type: ignore
            password: str | None = data.get("POSTGRES_PASSWORD")  # type: ignore
            if password_file is None and password is None:
                raise ValueError(
                    "At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set."
                )
        return data  # type: ignore

    @field_validator("POSTGRES_PASSWORD_FILE", mode="before")
    @classmethod
    def read_password_from_file(cls, v: str | None) -> str | None:
        if v is not None:
            file_path = v
            if os.path.exists(file_path):
                with open(file_path) as file:
                    return file.read().strip()
            raise ValueError(f"Password file {file_path} does not exist.")
        return v

    @computed_field
    @property
    def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
        url = MultiHostUrl.build(
            scheme="postgresql+psycopg",
            username=self.POSTGRES_USER,
            password=self.POSTGRES_PASSWORD
            if self.POSTGRES_PASSWORD
            else self.POSTGRES_PASSWORD_FILE,
            host=self.POSTGRES_SERVER,
            port=self.POSTGRES_PORT,
            path=self.POSTGRES_DB,
        )
        return PostgresDsn(url)


settings = Settings()  # type: ignore

{{< /file >}}

{{< file path="requirements.txt" >}}

# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/

fastapi==0.115.12
sqlmodel==0.0.24
psycopg[binary]==3.2.9
pydantic-settings==2.9.1
uvicorn==0.34.3

{{< /file >}}

{{< file path="Dockerfile" >}}

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/

# Use the dev image to build and install dependencies.
# The builder stage is also used directly in development (see compose.yaml).
FROM dhi.io/python:3.12-dev AS builder

WORKDIR /app

RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them
# into this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
    --mount=type=bind,source=requirements.txt,target=requirements.txt \
    pip install -r requirements.txt

# Copy the source code into the container.
COPY . .

# Expose the port that the application listens on.
EXPOSE 8000

# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]


# Use the minimal runtime image for production. It runs as nonroot by default.
FROM dhi.io/python:3.12

WORKDIR /app

COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"

COPY --from=builder /app .

EXPOSE 8000

CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]

{{< /file >}}

{{< file path="compose.yaml" status="modified" hl_lines="11-46" >}}

services:
  # Application service. The `target: builder` line builds the development
  # image (includes a shell and tools); the production stage of the
  # Dockerfile is unused in development.
  server:
    build:
      context: .
      target: builder
    ports:
      - 8000:8000
    environment:
      - POSTGRES_SERVER=db
      - POSTGRES_USER=postgres
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
  # Database service. Reads the password from a Docker secret mounted at
  # /run/secrets/db-password. Compose waits for the healthcheck to pass
  # before starting the server, via the server's depends_on.
  db:
    image: dhi.io/postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

{{< /file >}}

{{< file path="db/password.txt" status="new" >}}

mysecretpassword

{{< /file >}}

{{< file path=".dockerignore" >}}

# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

{{< /file >}}

{{< file path=".gitignore" >}}

# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Secrets
db/password.txt

{{< /file >}}

{{< /files >}}

[!NOTE]

To learn more about the instructions in the Compose file, see Compose file reference.

Now, run the following docker compose up command to start your application.

$ docker compose up --build

Now test your API endpoint. Open a new terminal then make a request to the server using the curl commands:

Create an object with a POST request:

$ curl -X 'POST' \
  'http://localhost:8000/heroes/' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "id": 1,
  "name": "my hero",
  "secret_name": "austing",
  "age": 12
}'

You should receive the following response:

{
  "age": 12,
  "id": 1,
  "name": "my hero",
  "secret_name": "austing"
}

Now make a GET request:

$ curl -X 'GET' \
  'http://localhost:8000/heroes/' \
  -H 'accept: application/json'

You should receive the same response as above because it's the only object in the database.

{
  "age": 12,
  "id": 1,
  "name": "my hero",
  "secret_name": "austing"
}

Press ctrl+c in the terminal to stop your application.

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Open your compose.yaml file in an IDE or text editor and add the highlighted Compose Watch instructions.

{{< files name="python-docker-example" >}}

{{< file path="app.py" >}}

# FastAPI application backed by a PostgreSQL database via SQLModel.
# The FastAPI lifespan handler creates database tables at startup.
# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list).
# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/

from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select

from config import settings


class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    secret_name: str
    age: int | None = Field(default=None, index=True)


engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))


def create_db_and_tables() -> None:
    SQLModel.metadata.create_all(engine)


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
    create_db_and_tables()
    yield


app = FastAPI(lifespan=lifespan)


@app.get("/")
def hello() -> str:
    return "Hello, Docker!"


@app.post("/heroes/")
def create_hero(hero: Hero) -> Hero:
    with Session(engine) as session:
        session.add(hero)
        session.commit()
        session.refresh(hero)
        return hero


@app.get("/heroes/")
def read_heroes() -> Sequence[Hero]:
    with Session(engine) as session:
        heroes = session.exec(select(Hero)).all()
        return heroes

{{< /file >}}

{{< file path="config.py" >}}

# Pydantic settings that read PostgreSQL connection details from the
# environment. Supports a password file (Docker secrets) via
# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD.
# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/

import os
from typing import Any

from pydantic import (
    PostgresDsn,
    computed_field,
    field_validator,
    model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    POSTGRES_SERVER: str
    POSTGRES_PORT: int = 5432
    POSTGRES_USER: str
    POSTGRES_PASSWORD: str | None = None
    POSTGRES_PASSWORD_FILE: str | None = None
    POSTGRES_DB: str

    @model_validator(mode="before")
    @classmethod
    def check_postgres_password(cls, data: Any) -> Any:
        """Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set."""
        if isinstance(data, dict):
            password_file: str | None = data.get("POSTGRES_PASSWORD_FILE")  # type: ignore
            password: str | None = data.get("POSTGRES_PASSWORD")  # type: ignore
            if password_file is None and password is None:
                raise ValueError(
                    "At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set."
                )
        return data  # type: ignore

    @field_validator("POSTGRES_PASSWORD_FILE", mode="before")
    @classmethod
    def read_password_from_file(cls, v: str | None) -> str | None:
        if v is not None:
            file_path = v
            if os.path.exists(file_path):
                with open(file_path) as file:
                    return file.read().strip()
            raise ValueError(f"Password file {file_path} does not exist.")
        return v

    @computed_field
    @property
    def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
        url = MultiHostUrl.build(
            scheme="postgresql+psycopg",
            username=self.POSTGRES_USER,
            password=self.POSTGRES_PASSWORD
            if self.POSTGRES_PASSWORD
            else self.POSTGRES_PASSWORD_FILE,
            host=self.POSTGRES_SERVER,
            port=self.POSTGRES_PORT,
            path=self.POSTGRES_DB,
        )
        return PostgresDsn(url)


settings = Settings()  # type: ignore

{{< /file >}}

{{< file path="requirements.txt" >}}

# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/

fastapi==0.115.12
sqlmodel==0.0.24
psycopg[binary]==3.2.9
pydantic-settings==2.9.1
uvicorn==0.34.3

{{< /file >}}

{{< file path="Dockerfile" >}}

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/

# Use the dev image to build and install dependencies.
# The builder stage is also used directly in development (see compose.yaml).
FROM dhi.io/python:3.12-dev AS builder

WORKDIR /app

RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them
# into this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
    --mount=type=bind,source=requirements.txt,target=requirements.txt \
    pip install -r requirements.txt

# Copy the source code into the container.
COPY . .

# Expose the port that the application listens on.
EXPOSE 8000

# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]


# Use the minimal runtime image for production. It runs as nonroot by default.
FROM dhi.io/python:3.12

WORKDIR /app

COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"

COPY --from=builder /app .

EXPOSE 8000

CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]

{{< /file >}}

{{< file path="compose.yaml" status="modified" hl_lines="21-24" >}}

services:
  # Application service. The `target: builder` line builds the development
  # image (includes a shell and tools); the production stage of the
  # Dockerfile is unused in development.
  server:
    build:
      context: .
      target: builder
    ports:
      - 8000:8000
    environment:
      - POSTGRES_SERVER=db
      - POSTGRES_USER=postgres
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
    develop:
      watch:
        - action: rebuild
          path: .
  db:
    image: dhi.io/postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

{{< /file >}}

{{< file path="db/password.txt" >}}

mysecretpassword

{{< /file >}}

{{< file path=".dockerignore" >}}

# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

{{< /file >}}

{{< file path=".gitignore" >}}

# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Secrets
db/password.txt

{{< /file >}}

{{< /files >}}

Run the following command to run your application with Compose Watch.

$ docker compose watch

In a terminal, curl the application to get a response.

$ curl http://localhost:8000
Hello, Docker!

Any changes to the application's source files on your local machine will now be immediately reflected in the running container.

Open python-docker-example/app.py in an IDE or text editor and update the Hello, Docker! string by adding a few more exclamation marks.

-    return 'Hello, Docker!'
+    return 'Hello, Docker!!!'

Save the changes to app.py and then wait a few seconds for the application to rebuild. Curl the application again and verify that the updated text appears.

$ curl http://localhost:8000
Hello, Docker!!!

Press ctrl+c in the terminal to stop your application.

Linting, formatting, and type checking for Python

Prerequisites

Complete Develop your app. This topic requires a local Python installation because the tools and Git hooks introduced here run on your host. If you don't want to install Python locally, skip this topic. You can also run the same checks in CI.

Overview

Linting, formatting, and type checking are automated ways to catch bugs, enforce style, and spot type errors before code runs. Running them on every commit, in CI, and in your editor catches problems early when they're cheap to fix.

In this section, you'll configure three tools for your Python application. Ruff handles linting and formatting in a single fast pass. Pyright statically checks your code for type errors. Pre-commit hooks run both of these automatically before each Git commit so problems are caught locally before they're committed.

Linting and formatting with Ruff

Ruff is an extremely fast Python linter and formatter written in Rust. It replaces multiple tools like flake8, isort, and black with a single unified tool.

Create a pyproject.toml file in your python-docker-example directory:

{{< files name="python-docker-example" >}}

{{< file path="pyproject.toml" status="new" >}}

# Configuration for code-quality tools.
# - [tool.ruff]: linting and formatting (https://docs.astral.sh/ruff/)
# - [tool.pyright]: static type checking (https://microsoft.github.io/pyright/)

[tool.ruff]
target-version = "py312"

[tool.ruff.lint]
select = [
    "E",  # pycodestyle errors
    "W",  # pycodestyle warnings
    "F",  # pyflakes
    "I",  # isort
    "B",  # flake8-bugbear
    "C4",  # flake8-comprehensions
    "UP",  # pyupgrade
    "ARG001", # unused arguments in functions
]
ignore = [
    "E501",  # line too long, handled by black
    "B008",  # do not perform function calls in argument defaults
    "W191",  # indentation contains tabs
    "B904",  # Allow raising exceptions without from e, for HTTPException
]

{{< /file >}}

{{< /files >}}

Install Ruff:

$ pip install ruff

If you're using a virtual environment, make sure it is activated so the ruff command is available.

Run these commands to check and format your code:

# Check for errors
$ ruff check .

# Automatically fix fixable errors
$ ruff check --fix .

# Format code
$ ruff format .

Type checking with Pyright

Pyright is a fast static type checker for Python that works well with modern Python features.

Update pyproject.toml to add the Pyright configuration at the bottom.

{{< files name="python-docker-example" >}}

{{< file path="pyproject.toml" status="modified" hl_lines="25-29" >}}

# Configuration for code-quality tools.
# - [tool.ruff]: linting and formatting (https://docs.astral.sh/ruff/)
# - [tool.pyright]: static type checking (https://microsoft.github.io/pyright/)

[tool.ruff]
target-version = "py312"

[tool.ruff.lint]
select = [
    "E",  # pycodestyle errors
    "W",  # pycodestyle warnings
    "F",  # pyflakes
    "I",  # isort
    "B",  # flake8-bugbear
    "C4",  # flake8-comprehensions
    "UP",  # pyupgrade
    "ARG001", # unused arguments in functions
]
ignore = [
    "E501",  # line too long, handled by black
    "B008",  # do not perform function calls in argument defaults
    "W191",  # indentation contains tabs
    "B904",  # Allow raising exceptions without from e, for HTTPException
]

[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"
exclude = [".venv"]

{{< /file >}}

{{< /files >}}

Install Pyright and run it:

$ pip install pyright
$ pyright

Setting up pre-commit hooks

Pre-commit hooks run checks automatically before each commit on your local machine. Create a .pre-commit-config.yaml file in your python-docker-example directory to set up Ruff hooks:

{{< files name="python-docker-example" >}}

{{< file path=".pre-commit-config.yaml" status="new" >}}

# Pre-commit hook configuration. Runs Ruff (lint + format) on every
# `git commit`. See https://pre-commit.com/

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.15.15
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

{{< /file >}}

{{< /files >}}

To install and use:

$ pip install pre-commit
$ pre-commit install
$ git commit -m "Test commit"  # Automatically runs checks

Summary

In this section, you learned how to:

These tools help maintain code quality and catch errors early in development.

Next steps

R language-specific guide

The R language-specific guide teaches you how to containerize a R application using Docker. In this guide, you’ll learn how to:

Start by containerizing an existing R application.

Containerize a R application

Prerequisites

Overview

This section walks you through containerizing and running a R application.

Get the sample application

The sample application uses the popular Shiny framework.

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository:

$ git clone https://github.com/mfranzon/r-docker-dev.git && cd r-docker-dev

You should now have the following contents in your r-docker-dev directory.

├── r-docker-dev/
│ ├── src/
│ │ └── app.R
│ ├── src_db/
│ │ └── app_db.R
│ ├── compose.yaml
│ ├── Dockerfile
│ └── README.md

To learn more about the files in the repository, see the following:

Run the application

Inside the r-docker-dev directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:3838. You should see a simple Shiny application.

In the terminal, press ctrl+c to stop the application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the r-docker-dev directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:3838.

You should see a simple Shiny application.

In the terminal, run the following command to stop the application.

$ docker compose down

For more information about Compose commands, see the Compose CLI reference.

Use containers for R development

Prerequisites

Complete Containerize a R application.

Overview

In this section, you'll learn how to set up a development environment for your containerized application. This includes:

Get the sample application

You'll need to clone a new repository to get a sample application that includes logic to connect to the database.

Change to a directory where you want to clone the repository and run the following command.

$ git clone https://github.com/mfranzon/r-docker-dev.git

Configure the application to use the database

To try the connection between the Shiny application and the local database you have to modify the Dockerfile changing the COPY instruction:

-COPY src/ .
+COPY src_db/ .

Add a local database and persist data

You can use containers to set up local services, like a database. In this section, you'll update the compose.yaml file to define a database service and a volume to persist data.

In the cloned repository's directory, open the compose.yaml file in an IDE or text editor.

In the compose.yaml file, you need to un-comment the properties for configuring the database. You must also mount the database password file and set an environment variable on the shiny-app service pointing to the location of the file in the container.

The following is the updated compose.yaml file.

services:
  shiny-app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - 3838:3838
    environment:
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
  db:
    image: postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

[!NOTE]

To learn more about the instructions in the Compose file, see Compose file reference.

Before you run the application using Compose, notice that this Compose file specifies a password.txt file to hold the database's password. You must create this file as it's not included in the source repository.

In the cloned repository's directory, create a new directory named db and inside that directory create a file named password.txt that contains the password for the database. Using your favorite IDE or text editor, add the following contents to the password.txt file.

mysecretpassword

Save and close the password.txt file.

You should now have the following contents in your r-docker-dev directory.

├── r-docker-dev/
│ ├── db/
│ │ └── password.txt
│ ├── src/
│ │ └── app.R
│ ├── src_db/
│ │ └── app_db.R
│ ├── requirements.txt
│ ├── .dockerignore
│ ├── compose.yaml
│ ├── Dockerfile
│ └── README.md

Now, run the following docker compose up command to start your application.

$ docker compose up --build

Now test your DB connection opening a browser at:

http://localhost:3838

You should see a pop-up message:

DB CONNECTED

Press ctrl+c in the terminal to stop your application.

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Lines 15 to 18 in the compose.yaml file contain properties that trigger Docker to rebuild the image when a file in the current working directory is changed:

services:
  shiny-app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - 3838:3838
    environment:
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    depends_on:
      db:
        condition: service_healthy
    secrets:
      - db-password
    develop:
      watch:
        - action: rebuild
          path: .
  db:
    image: postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

Run the following command to run your application with Compose Watch.

$ docker compose watch

Now, if you modify your app.R you will see the changes in real time without re-building the image!

Press ctrl+c in the terminal to stop your application.

Summary

In this section, you took a look at setting up your Compose file to add a local database and persist data. You also learned how to use Compose Watch to automatically rebuild and run your container when you update your code.

React.js language-specific guide

The React.js language-specific guide shows you how to containerize a React.js application using Docker, following best practices for creating efficient, production-ready containers.

React.js is a widely used library for building interactive user interfaces. However, managing dependencies, environments, and deployments efficiently can be complex. Docker simplifies this process by providing a consistent and containerized environment.

Acknowledgment

Docker extends its sincere gratitude to Kristiyan Velkov for authoring this guide. As a Docker Captain and experienced Front-end engineer, his expertise in Docker, DevOps, and modern web development has made this resource invaluable for the community, helping developers navigate and optimize their Docker workflows.


What will you learn?

In this guide, you will learn how to:

To begin, you’ll start by containerizing an existing React.js application.


Prerequisites

Before you begin, make sure you're familiar with the following:

Once you've completed the React.js getting started modules, you’ll be ready to containerize your own React.js application using the examples and instructions provided in this guide.

Containerize a React.js Application

Prerequisites

Before you begin, make sure the following tools are installed and available on your system:

New to Docker?
Start with the Docker basics guide to get familiar with key concepts like images, containers, and Dockerfiles.


Overview

This guide walks you through the complete process of containerizing a React.js application with Docker. You’ll learn how to create a production-ready Docker image using best practices that improve performance, security, scalability, and deployment efficiency.

By the end of this guide, you will:


Get the sample application

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the git repository:

$ git clone https://github.com/kristiyan-velkov/docker-reactjs-sample

Build the Docker image

React.js is a front-end library that compiles into static assets, so the Dockerfile is tailored to optimize how React applications are built and served in a production environment.

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

Step 1: Create the Dockerfile

Before creating a Dockerfile, you need to choose a base image. You can either use the Node.js Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog.

Choosing DHI offers the advantage of a production-ready image that is lightweight and secure. For more information, see Docker Hardened Images.

[!IMPORTANT] This guide uses a stable Node.js LTS image tag that is considered secure when the guide is written. Because new releases and security patches are published regularly, the tag shown here may no longer be the safest option when you follow the guide. Always review the latest available image tags and select a secure, up-to-date version before building or deploying your application.

Official Node.js Docker Images: https://hub.docker.com/_/node

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}} Docker Hardened Images (DHIs) are available for Node.js in the Docker Hardened Images catalog. Docker Hardened Images are freely available to everyone with no subscription required. You can pull and use them like any other Docker image after signing in to the DHI registry. For more information, see the DHI quickstart guide.

  1. Sign in to the DHI registry:

    $ docker login dhi.io
    
  2. Pull the Node.js DHI (check the catalog for available versions):

    $ docker pull dhi.io/node:24-alpine3.22-dev
    
  3. Pull the Nginx DHI (check the catalog for available versions):

    $ docker pull dhi.io/nginx:1.28.0-alpine3.21-dev
    

In the following Dockerfile, the FROM instructions use dhi.io/node:24-alpine3.22-dev and dhi.io/nginx:1.28.0-alpine3.21-dev as the base images.

# =========================================
# Stage 1: Build the React.js Application
# =========================================

# Use a lightweight Node.js image for building (customizable via ARG)
FROM dhi.io/node:24-alpine3.22-dev AS builder

# Set the working directory inside the container
WORKDIR /app

# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json package-lock.json* ./

# Install project dependencies using npm ci (ensures a clean, reproducible install)
RUN --mount=type=cache,target=/root/.npm npm ci

# Copy the rest of the application source code into the container
COPY . .

# Build the React.js application (outputs to /app/dist)
RUN npm run build

# =========================================
# Stage 2: Prepare Nginx to Serve Static Files
# =========================================

FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner

# Copy custom Nginx config
COPY nginx.conf /etc/nginx/nginx.conf

# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html

# Use a non-root user for security best practices
USER nginx

# Expose port 8080 to allow HTTP traffic
# Note: The default NGINX container now listens on port 8080 instead of 80
EXPOSE 8080

# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]

{{< /tab >}} {{< tab name="Using the Docker Official Image" >}}

Create a file named Dockerfile with the following contents:

# =========================================
# Stage 1: Build the React.js Application
# =========================================
ARG NODE_VERSION=24.12.0-alpine
ARG NGINX_VERSION=alpine3.22

# Use a lightweight Node.js image for building (customizable via ARG)
FROM node:${NODE_VERSION} AS builder

# Set the working directory inside the container
WORKDIR /app

# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json package-lock.json* ./

# Install project dependencies using npm ci (ensures a clean, reproducible install)
RUN --mount=type=cache,target=/root/.npm npm ci

# Copy the rest of the application source code into the container
COPY . .

# Build the React.js application (outputs to /app/dist)
RUN npm run build

# =========================================
# Stage 2: Prepare Nginx to Serve Static Files
# =========================================

FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runner

# Copy custom Nginx config
COPY nginx.conf /etc/nginx/nginx.conf

# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html

# Use a built-in non-root user for security best practices
USER nginx

# Expose port 8080 to allow HTTP traffic
# Note: The default NGINX container now listens on port 8080 instead of 80
EXPOSE 8080

# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]

[!NOTE] We are using nginx-unprivileged instead of the standard NGINX image to follow security best practices. Running as a non-root user in the final image:

{{< /tab >}} {{< /tabs >}}

Step 2: Create the compose.yaml file

Create a file named compose.yaml with the following contents:

services:
  server:
    build:
      context: .
    ports:
      - 8080:8080

Step 3: Create the .dockerignore file

The .dockerignore file tells Docker which files and folders to exclude when building the image.

[!NOTE] This helps:

To learn more, visit the .dockerignore reference.

Create a file named .dockerignore with the following contents:

# Ignore dependencies and build output
node_modules/
dist/
out/
.tmp/
.cache/

# Ignore Vite, Webpack, and React-specific build artifacts
.vite/
.vitepress/
.eslintcache
.npm/
coverage/
jest/
cypress/
cypress/screenshots/
cypress/videos/
reports/

# Ignore environment and config files (sensitive data)
*.env*
*.log

# Ignore TypeScript build artifacts (if using TypeScript)
*.tsbuildinfo

# Ignore lockfiles (optional if using Docker for package installation)
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# Ignore local development files
.git/
.gitignore
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db

# Ignore Docker-related files (to avoid copying unnecessary configs)
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.override.yml

# Ignore build-specific cache files
*.lock

Step 4: Create the nginx.conf file

To serve your React.js application efficiently inside the container, you’ll configure NGINX with a custom setup. This configuration is optimized for performance, browser caching, gzip compression, and support for client-side routing.

Create a file named nginx.conf in the root of your project directory, and add the following content:

[!NOTE] To learn more about configuring NGINX, see the official NGINX documentation.

worker_processes auto;

# Store PID in /tmp (always writable)
pid /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # Disable logging to avoid permission issues
    access_log off;
    error_log  /dev/stderr warn;

    # Optimize static file serving
    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;
    keepalive_requests 1000;

    # Gzip compression for optimized delivery
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
    gzip_min_length 256;
    gzip_vary on;

    server {
        listen       8080;
        server_name  localhost;

        # Root directory where React.js build files are placed
        root /usr/share/nginx/html;
        index index.html;

        # Serve React.js static files with proper caching
        location / {
            try_files $uri /index.html;
        }

        # Serve static assets with long cache expiration
        location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|map)$ {
            expires 1y;
            access_log off;
            add_header Cache-Control "public, immutable";
        }

        # Handle React.js client-side routing
        location /static/ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }
}

Step 5: Build the React.js application image

With your custom configuration in place, you're now ready to build the Docker image for your React.js application.

The updated setup includes:

After completing the previous steps, your project directory should now contain the following files:

├── docker-reactjs-sample/
│ ├── Dockerfile
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf

Now that your Dockerfile is configured, you can build the Docker image for your React.js application.

[!NOTE] The docker build command packages your application into an image using the instructions in the Dockerfile. It includes all necessary files from the current directory (called the build context).

Run the following command from the root of your project:

$ docker build --tag docker-reactjs-sample .

What this command does:

Step 6: View local images

After building your Docker image, you can check which images are available on your local machine using either the Docker CLI or Docker Desktop. Since you're already working in the terminal, let's use the Docker CLI.

To list all locally available Docker images, run the following command:

$ docker images

Example Output:

REPOSITORY                TAG               IMAGE ID       CREATED         SIZE
docker-reactjs-sample     latest            f39b47a97156   14 seconds ago   75.8MB

This output provides key details about your images:

If the build was successful, you should see docker-reactjs-sample image listed.


Run the containerized application

In the previous step, you created a Dockerfile for your React.js application and built a Docker image using the docker build command. Now it’s time to run that image in a container and verify that your application works as expected.

Inside the docker-reactjs-sample directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:8080. You should see a simple React.js web application.

Press ctrl+c in the terminal to stop your application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the docker-reactjs-sample directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:8080. You should see a simple web application preview.

To confirm that the container is running, use docker ps command:

$ docker ps

This will list all active containers along with their ports, names, and status. Look for a container exposing port 8080.

Example Output:

CONTAINER ID   IMAGE                          COMMAND                  CREATED             STATUS             PORTS                    NAMES
88bced6ade95   docker-reactjs-sample-server   "nginx -c /etc/nginx…"   About a minute ago  Up About a minute  0.0.0.0:8080->8080/tcp   docker-reactjs-sample-server-1

To stop the application, run:

$ docker compose down

[!NOTE] For more information about Compose commands, see the Compose CLI reference.


Use containers for React.js development

Prerequisites

Complete Containerize React.js application.


Overview

In this section, you'll learn how to set up both production and development environments for your containerized React.js application using Docker Compose. This setup allows you to serve a static production build via Nginx and to develop efficiently inside containers using a live-reloading dev server with Compose Watch.

You’ll learn how to:


Automatically update services (development mode)

Use Compose Watch to automatically sync source file changes into your containerized development environment. This provides a seamless, efficient development experience without needing to restart or rebuild containers manually.

Step 1: Create a development Dockerfile

Create a file named Dockerfile.dev in your project root with the following content:

# =========================================
# Stage 1: Develop the React.js Application
# =========================================
ARG NODE_VERSION=24.12.0-alpine

# Use a lightweight Node.js image for development
FROM node:${NODE_VERSION} AS dev

# Set the working directory inside the container
WORKDIR /app

# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json package-lock.json* ./

# Install project dependencies
RUN --mount=type=cache,target=/root/.npm npm install

# Copy the rest of the application source code into the container
COPY . .

# Expose the port used by the Vite development server
EXPOSE 5173

# Use a default command, can be overridden in Docker compose.yml file
CMD ["npm", "run", "dev"]

This file sets up a lightweight development environment for your React app using the dev server.

Step 2: Update your compose.yaml file

Open your compose.yaml file and define two services: one for production (react-prod) and one for development (react-dev).

Here’s an example configuration for a React.js application:

services:
  react-prod:
    build:
      context: .
      dockerfile: Dockerfile
    image: docker-reactjs-sample
    ports:
      - "8080:8080"

  react-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "5173:5173"
    develop:
      watch:
        - action: sync
          path: .
          target: /app

[!NOTE] For more details, see the official guide: Use Compose Watch.

Step 3: Update vite.config.ts to ensure it works properly inside Docker

To make Vite’s development server work reliably inside Docker, you need to update your vite.config.ts with the correct settings.

Open the vite.config.ts file in your project root and update it as follows:

/// <reference types="vitest" />

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  base: "/",
  plugins: [react()],
  server: {
    host: true,
    port: 5173,
    strictPort: true,
  },
});

[!NOTE] The server options in vite.config.ts are essential for running Vite inside Docker:

For full details, refer to the Vite server configuration docs.

After completing the previous steps, your project directory should now contain the following files:

├── docker-reactjs-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf

Step 4: Start Compose Watch

Run the following command from your project root to start your container in watch mode:

$ docker compose watch react-dev

Step 5: Test Compose Watch with React

To verify that Compose Watch is working correctly:

  1. Open the src/App.tsx file in your text editor.

  2. Locate the following line:

    <h1>Vite + React</h1>
    
  3. Change it to:

    <h1>Hello from Docker Compose Watch</h1>
    
  4. Save the file.

  5. Open your browser at http://localhost:5173.

You should see the updated text appear instantly, without needing to rebuild the container manually. This confirms that file watching and automatic synchronization are working as expected.


Run React.js tests in a container

Prerequisites

Complete all the previous sections of this guide, starting with Containerize React.js application.

Overview

Testing is a critical part of the development process. In this section, you'll learn how to:

You’ll use Vitest — a blazing fast test runner designed for Vite — along with Testing Library for assertions.


Run tests during development

docker-reactjs-sample application includes a sample test file at location:

$ src/App.test.tsx

This file uses Vitest and React Testing Library to verify the behavior of App component.

Step 1: Install Vitest and React Testing Library

If you haven’t already added the necessary testing tools, install them by running:

$ npm install --save-dev vitest @testing-library/react @testing-library/jest-dom jsdom

Then, update the scripts section of your package.json file to include the following:

"scripts": {
  "test": "vitest run"
}

Step 2: Configure Vitest

Update vitest.config.ts file in your project root with the following configuration:

/// <reference types="vitest" />

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  base: "/",
  plugins: [react()],
  server: {
    host: true,
    port: 5173,
    strictPort: true,
  },
  test: {
    environment: "jsdom",
    setupFiles: "./src/setupTests.ts",
    globals: true,
  },
});

[!NOTE] The test options in vitest.config.ts are essential for reliable testing inside Docker:

For more details, see the official Vitest configuration docs.

Step 3: Update compose.yaml

Add a new service named react-test to your compose.yaml file. This service allows you to run your test suite in an isolated containerized environment.

services:
  react-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "5173:5173"
    develop:
      watch:
        - action: sync
          path: .
          target: /app

  react-prod:
    build:
      context: .
      dockerfile: Dockerfile
    image: docker-reactjs-sample
    ports:
      - "8080:8080"

  react-test:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: ["npm", "run", "test"]

The react-test service reuses the same Dockerfile.dev used for development and overrides the default command to run tests with npm run test. This setup ensures a consistent test environment that matches your local development configuration.

After completing the previous steps, your project directory should contain the following files:

├── docker-reactjs-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf

Step 4: Run the tests

To execute your test suite inside the container, run the following command from your project root:

$ docker compose run --rm react-test

This command will:

[!NOTE] For more information about Compose commands, see the Compose CLI reference.


Summary

In this section, you learned how to run unit tests for your React.js application inside a Docker container using Vitest and Docker Compose.

What you accomplished:


Explore official references and best practices to sharpen your Docker testing workflow:

Introduction to ROS 2 Development with Docker

Acknowledgment

This guide is a community contribution. Docker would like to thank Shakirth Anisha for her contribution to this guide.

ROS 2 is a set of software libraries and tools for building robot applications. It uses Data Distribution Service (DDS) for real-time, secure communication between distributed nodes, making it ideal for robotics and autonomous systems.


What will you learn?

In this guide, you'll learn how to:

Prerequisites

Before you begin, make sure you're familiar with the following:

What's next?

Start by setting up your ROS 2 development environment using Docker and dev containers.

Run ROS 2 in a container

Overview

In this section, you will run ROS 2 in an isolated Docker container using official ROS 2 images, verify that ROS 2 is working, and install additional ROS 2 packages for development and testing.


Run ROS 2 in a container

The fastest way to get started with ROS 2 is to use the official Docker image. To pull an image, start a container, and open an interactive bash shell:

  1. Pull and run the official ROS 2 Docker image:

    $ docker run -it ros:humble
    

    This guide uses the Humble distribution. You can replace humble with another supported distribution such as rolling, jazzy, or iron.

    [!NOTE]

    This environment is temporary and does not maintain persistence. Any files you create or packages you install will be deleted once the container is stopped or removed.

  2. Verify ROS 2 is working:

    $ echo $ROS_DISTRO
    

    You should see output similar to:

    humble
    

Install ROS 2 packages

The official ROS 2 images include core packages. To install additional packages, use the apt package manager:

  1. Update the package manager:

    $ sudo apt update
    
  2. Install the desired package:

    $ sudo apt install $PACKAGE_NAME
    

Replace $PACKAGE_NAME with any package you want to install.

Some commonly used packages include:

Summary

In this section, you pulled an official ROS 2 Docker image, launched an interactive session, and extended the container's capabilities by installing additional ROS 2 packages using apt.

Next steps

In the next section, you will configure a persistent workspace to ensure your code and modifications are saved across sessions.

Build and develop a ROS 2 workspace

Overview

In this section, you will set up a ROS 2 workspace using Docker and development containers, review the workspace layout, open the workspace in Visual Studio Code, and edit and build ROS 2 projects inside the container.


Get the sample ROS 2 workspace

A consistent workspace simplifies managing ROS 2 projects and build artifacts across different distributions.

  1. Open a terminal and clone the sample workspace repository:

    $ git clone https://github.com/shakirth-anisha/docker-ros2-workspace.git
    $ cd docker-ros2-workspace
    
    

    Moving forward, Linux users can use the ws_linux folder, and macOS users can use ws_mac.

  2. Verify the workspace structure:

    ws_linux/
    ├── compose.yml
    ├── Dockerfile
    └── src/
        ├── package1/
        └── package2/
    
    ws_mac/
    ├── compose.yml
    ├── Dockerfile
    └── src/
        ├── package1/
        └── package2/
    
    
  3. Explore the workspace layout

Open and build the container

  1. Execute the following commands to build and start the container:

    For Linux:

    $ cd ws_linux
    $ docker compose up -d
    $ docker compose exec ros2 /bin/bash
    

    For macOS:

    $ cd ws_mac
    $ docker compose up -d
    $ docker compose exec ros2 /bin/bash
    

    This command builds the Docker image defined in your Dockerfile and starts the container in the background.

    [!NOTE]

    Building the image may take several minutes during the first run as the CLI pulls the base ROS 2 image and installs required dependencies. Subsequent starts will be significantly faster.

  2. Once the container is running, execute commands inside it using exec:

    $ docker compose exec ros2 /bin/bash
    
  3. Inside the container terminal, verify the environment:

$ echo $ROS_VERSION
$ which colcon

All commands should execute successfully inside the container.

Switch ROS 2 distributions

Update the base image in your Dockerfile, changing from humble to another distribution like rolling, jazzy, or iron.

Summary

In this section, you learned how to create a structured workspace, write a Dockerfile with development tools, and configure a Docker Compose setup. Your ROS 2 development environment is now ready with a consistent, reproducible setup across any machine.

Next steps

In the next section, you'll run a complete end-to-end example with Turtlesim.

Run a complete example with Turtlesim

Overview

Turtlesim is a simple simulation tool that demonstrates fundamental ROS 2 concepts such as nodes, topics, and services. In this section, you'll run a complete example with Turtlesim, control the turtle, monitor topics, and visualize the system with rqt.


Configure display forwarding

Linux

Allow Docker access to your X server:

$ xhost +local:docker

macOS

On macOS, use XQuartz to provide X11 support. Install XQuartz using Homebrew:

  1. Install XQuartz using Homebrew:

    $ brew install --cask xquartz
    
  2. Open XQuartz from Applications, then navigate to Preferences > Security and enable Allow connections from network clients. Restart your computer to ensure the changes take effect.

  3. After rebooting, open a terminal and allow local connections:

    $ defaults write org.xquartz.X11 nolisten_tcp -bool false
    $ xhost +localhost
    $ xhost + 127.0.0.1
    

Start the container

Start the container using the same Docker Compose setup from the workspace section.

For Linux:

$ cd ws_linux
$ docker compose up -d
$ docker compose exec ros2 /bin/bash

For macOS:

$ cd ws_mac
$ docker compose up -d
$ docker compose exec ros2 /bin/bash

Install and run Turtlesim

Inside the container, install the Turtlesim package:

  1. Update the package manager:

    $ sudo apt update
    
  2. Install the Turtlesim package:

    $ sudo apt install -y ros-humble-turtlesim
    
  3. Run the Turtlesim node:

    $ ros2 run turtlesim turtlesim_node
    

A window should appear on your desktop showing a turtle in a grid.

Control the turtle

  1. Open a new terminal and connect to the same container, then start the keyboard teleop node:

    $ ros2 run turtlesim turtle_teleop_key
    

    This node allows you to control the turtle using your keyboard. Use the arrow keys to move the turtle forward, backward, left, and right. Press Ctrl+C to stop the teleop node.

  2. Move the turtle around the window. You should see it draw a path as it moves.

Monitor topics

  1. Open another terminal and connect to the same container, then list all active topics:

    $ ros2 topic list
    

    You should see output similar to the following:

    /parameter_events
    /rosout
    /turtle1/cmd_vel
    /turtle1/color_sensor
    /turtle1/pose
    
  2. Get information about a specific topic:

    $ ros2 topic info /turtle1/pose
    

    You'll see the topic type and which nodes publish and subscribe to it.

Visualize the system with rqt

  1. Open another terminal and connect to the same container, then update the package manager:

    $ sudo apt update
    
  2. Install rqt:

    $ sudo apt install -y 'ros-humble-rqt*'
    
  3. Start rqt:

    $ ros2 run rqt_gui rqt_gui
    

An rqt window should appear. rqt provides several useful plugins for visualizing and monitoring ROS 2 systems.

Node Graph

You can explore the node graph by navigating to Plugins > Introspection > Node Graph. A new tab opens showing nodes and topics with connections illustrated as lines. This visualization demonstrates how the teleop node sends velocity commands to the Turtlesim node, and how the Turtlesim node publishes position data back through topics.

Topic Monitor

You can monitor active topics by navigating to Plugins > Topics > Topic Monitor. A new tab opens displaying all active topics and their current values. Select the eye icon next to /turtle1/pose to monitor it. As you move the turtle, watch the pose values update in real time, showing the position of the turtle and orientation changing based on your commands.

Service Caller

You can call services from rqt using Plugins > Services > Service Caller. Select a service such as /turtle1/teleport_absolute, enter values for the request fields, and select Call to send the request.

Plots

To plot topic data over time navigate to Plugins > Visualization > Plot. For example, in the Plot window, type /turtle1/pose/x in the Topic field and press Enter. Move the turtle and watch the X position displayed as a graph over time.

Call ROS 2 services

Turtlesim provides services for actions such as repositioning the turtle and clearing the path.

  1. List available services:

    $ ros2 service list
    

    You should see services such as /turtle1/set_pen (to change pen color and width), /turtle1/teleport_absolute (to move the turtle to a specific position), and /turtle1/teleport_relative (to move the turtle relative to its current position).

  2. Teleport the turtle to a new position:

    $ ros2 service call /turtle1/teleport_absolute turtlesim/srv/TeleportAbsolute "
    x: 1.0
    y: 3.0
    theta: 0.0
    "
    

    The turtle should instantly move to the specified position (1.0, 3.0).

Create a simple publisher

  1. Create a Python script that publishes velocity commands to control the turtle programmatically. In a new terminal, create a file called move_turtle.py:

    import rclpy
    from geometry_msgs.msg import Twist
    import time
    
    def main():
        rclpy.init()
        node = rclpy.create_node('turtle_mover')
        publisher = node.create_publisher(Twist, 'turtle1/cmd_vel', 10)
    
        # Create a twist message
        msg = Twist()
        msg.linear.x = 2.0  # Move forward at 2 m/s
        msg.angular.z = 1.0  # Rotate at 1 rad/s
    
        # Publish the message
        for i in range(50):
            publisher.publish(msg)
            time.sleep(0.1)
    
        # Stop the turtle
        msg.linear.x = 0.0
        msg.angular.z = 0.0
        publisher.publish(msg)
    
        node.destroy_node()
        rclpy.shutdown()
    
    if __name__ == '__main__':
        main()
    
  2. Run the script:

    $ python3 move_turtle.py
    

    The turtle should move in a circular motion for 5 seconds and then stop.

Summary

In this section, you configured display forwarding, used the Turtlesim nodes, inspected nodes and topics, and visualized the system using rqt. Finally, you interacted with ROS 2 services and created a simple publisher to move the turtle programmatically.

These fundamental concepts apply directly to real-world robotics applications with actual sensors and actuators.

Ruby on Rails language-specific guide

The Ruby language-specific guide teaches you how to containerize a Ruby on Rails application using Docker. In this guide, you’ll learn how to:

Start by containerizing an existing Ruby on Rails application.

Containerize a Ruby on Rails application

Prerequisites

Overview

This section walks you through containerizing and running a Ruby on Rails application.

Starting from Rails 7.1 Docker is supported out of the box. This means that you will get a Dockerfile, .dockerignore and bin/docker-entrypoint files generated for you when you create a new Rails application.

If you have an existing Rails application, you will need to create the Docker assets manually from the examples below.

1. Create Docker assets

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

Rails 7.1 and newer generates multistage Dockerfile out of the box. Following are two versions of such a file: one using Docker Hardened Images (DHIs) and another using the Docker Official Image (DOIs). Although the Dockerfile is generated automatically, understanding its purpose and functionality is important. Reviewing the following example is highly recommended.

Docker Hardened Images (DHIs) are minimal, secure, and production-ready container base and application images maintained by Docker. DHIs are recommended whenever it is possible for better security. They are designed to reduce vulnerabilities and simplify compliance, freely available to everyone with no subscription required, no usage restrictions, and no vendor lock-in.

Multistage Dockerfiles help create smaller, more efficient images by separating build and runtime dependencies, ensuring only necessary components are included in the final image. Read more in the Multi-stage builds guide.

{{< tabs >}} {{< tab name="Using DHIs" >}}

You must authenticate to dhi.io before you can pull Docker Hardened Images. Run docker login dhi.io to authenticate.

# syntax=docker/dockerfile:1
# check=error=true

# This Dockerfile is designed for production, not development.
# docker build -t app .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name app app

# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.8
FROM dhi.io/ruby:$RUBY_VERSION-dev AS base

# Rails app lives here
WORKDIR /rails

# Install base packages
# Replace libpq-dev with sqlite3 if using SQLite, or libmysqlclient-dev if using MySQL
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libjemalloc2 libvips libpq-dev && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential curl git pkg-config libyaml-dev && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Install JavaScript dependencies and Node.js for asset compilation
#
# Uncomment the following lines if you are using NodeJS need to compile assets
#
# ARG NODE_VERSION=18.12.0
# ARG YARN_VERSION=1.22.19
# ENV PATH=/usr/local/node/bin:$PATH
# RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
#     /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
#     npm install -g yarn@$YARN_VERSION && \
#     npm install -g mjml && \
#     rm -rf /tmp/node-build-master

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile

# Install node modules
#
# Uncomment the following lines if you are using NodeJS need to compile assets
#
# COPY package.json yarn.lock ./
# RUN --mount=type=cache,id=yarn,target=/rails/.cache/yarn YARN_CACHE_FOLDER=/rails/.cache/yarn \
#     yarn install --frozen-lockfile

# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

# Final stage for app image
FROM base

# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]

{{< /tab >}} {{< tab name="Using DOIs" >}}

# syntax=docker/dockerfile:1
# check=error=true

# This Dockerfile is designed for production, not development.
# docker build -t app .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name app app

# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.8
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base

# Rails app lives here
WORKDIR /rails

# Install base packages
# Replace libpq-dev with sqlite3 if using SQLite, or libmysqlclient-dev if using MySQL
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libjemalloc2 libvips libpq-dev && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential curl git pkg-config libyaml-dev && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Install JavaScript dependencies and Node.js for asset compilation
#
# Uncomment the following lines if you are using NodeJS need to compile assets
#
# ARG NODE_VERSION=18.12.0
# ARG YARN_VERSION=1.22.19
# ENV PATH=/usr/local/node/bin:$PATH
# RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
#     /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
#     npm install -g yarn@$YARN_VERSION && \
#     npm install -g mjml && \
#     rm -rf /tmp/node-build-master

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile

# Install node modules
#
# Uncomment the following lines if you are using NodeJS need to compile assets
#
# COPY package.json yarn.lock ./
# RUN --mount=type=cache,id=yarn,target=/rails/.cache/yarn YARN_CACHE_FOLDER=/rails/.cache/yarn \
#     yarn install --frozen-lockfile

# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

# Final stage for app image
FROM base

# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]

{{< /tab >}} {{< /tabs >}}

The Dockerfile above assumes you are using Thruster together with Puma as an application server. In case you are using any other server, you can replace the last three lines with the following:

# Start the application server
EXPOSE 3000
CMD ["./bin/rails", "server"]

This Dockerfile uses a script at ./bin/docker-entrypoint as the container's entrypoint. This script prepares the database and runs the application server. Below is an example of such a script.

#!/bin/bash -e

# Enable jemalloc for reduced memory usage and latency.
if [ -z "${LD_PRELOAD+x}" ]; then
    LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit)
    export LD_PRELOAD
fi

# If running the rails server then create or migrate existing database
if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
  ./bin/rails db:prepare
fi

exec "${@}"

Besides the two files above you will also need a .dockerignore file. This file is used to exclude files and directories from the context of the build. Below is an example of a .dockerignore file.

# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.

# Ignore git directory.
/.git/
/.gitignore

# Ignore bundler config.
/.bundle

# Ignore all environment files.
/.env*

# Ignore all default key files.
/config/master.key
/config/credentials/*.key

# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep

# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/.keep

# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/.keep

# Ignore assets.
/node_modules/
/app/assets/builds/*
!/app/assets/builds/.keep
/public/assets

# Ignore CI service files.
/.github

# Ignore development files
/.devcontainer

# Ignore Docker-related files
/.dockerignore
/Dockerfile*

The last optional file that you may want is the compose.yaml file, which is used by Docker Compose to define the services that make up the application. Since SQLite is being used as the database, there is no need to define a separate service for the database. The only service required is the Rails application itself.

services:
  web:
    build: .
    environment:
      - RAILS_MASTER_KEY
    ports:
      - "3000:80"

You should now have the following files in your application folder:

To learn more about the files, see the following:

2. Run the application

To run the application, run the following command in a terminal inside the application's directory.

$ RAILS_MASTER_KEY=<master_key_value> docker compose up --build

Open a browser and view the application at http://localhost:3000. You should see a simple Ruby on Rails application.

In the terminal, press ctrl+c to stop the application.

3. Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the docker-ruby-on-rails directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:3000.

You should see a simple Ruby on Rails application.

In the terminal, run the following command to stop the application.

$ docker compose down

For more information about Compose commands, see the Compose CLI reference.

Use containers for Ruby on Rails development

Prerequisites

Complete Containerize a Ruby on Rails application.

Overview

In this section, you'll learn how to set up a development environment for your containerized application. This includes:

Add a local database and persist data

You can use containers to set up local services, like a database. In this section, you'll update the compose.yaml file to define a database service and a volume to persist data.

In the cloned repository's directory, open the compose.yaml file in an IDE or text editor. You need to add the database password file as an environment variable to the server service and specify the secret file to use.

The following is the updated compose.yaml file.

services:
  web:
    build: .
    command: bundle exec rails s -b '0.0.0.0'
    ports:
      - "3000:3000"
    depends_on:
      - db
    environment:
      - RAILS_ENV=test
    env_file: "webapp.env"
  db:
    image: postgres:18
    secrets:
      - db-password
    environment:
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    volumes:
      - postgres_data:/var/lib/postgresql

volumes:
  postgres_data:
secrets:
  db-password:
    file: db/password.txt

[!NOTE]

To learn more about the instructions in the Compose file, see Compose file reference.

Before you run the application using Compose, notice that this Compose file specifies a password.txt file to hold the database's password. You must create this file as it's not included in the source repository.

In the cloned repository's directory, create a new directory named db and inside that directory create a file named password.txt that contains the password for the database. Using your favorite IDE or text editor, add the following contents to the password.txt file.

mysecretpassword

Save and close the password.txt file. In addition, in the file webapp.env you can change the password to connect to the database.

You should now have the following contents in your docker-ruby-on-rails directory.

.
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── README.md
├── Rakefile
├── app/
├── bin/
├── compose.yaml
├── config/
├── config.ru
├── db/
│   ├── development.sqlite3
│   ├── migrate
│   ├── password.txt
│   ├── schema.rb
│   └── seeds.rb
├── lib/
├── log/
├── public/
├── storage/
├── test/
├── tmp/
└── vendor

Now, run the following docker compose up command to start your application.

$ docker compose up --build

In Ruby on Rails, db:migrate is a Rake task that is used to run migrations on the database. Migrations are a way to alter the structure of your database schema over time in a consistent and easy way.

$ docker exec -it docker-ruby-on-rails-web-1 rake db:migrate RAILS_ENV=test

You will see a similar message like this:

== 20240710193146 CreateWhales: migrating =====================================
-- create_table(:whales)
   -> 0.0126s
== 20240710193146 CreateWhales: migrated (0.0127s) ============================

Refresh http://localhost:3000 in your browser and add the whales.

Press ctrl+c in the terminal to stop your application and run docker compose up again, the whales are being persisted.

Automatically update services

Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.

Open your compose.yaml file in an IDE or text editor and then add the Compose Watch instructions. The following is the updated compose.yaml file.

services:
  web:
    build: .
    command: bundle exec rails s -b '0.0.0.0'
    ports:
      - "3000:3000"
    depends_on:
      - db
    environment:
      - RAILS_ENV=test
    env_file: "webapp.env"

    develop:
      watch:
        - action: rebuild
          path: .
  db:
    image: postgres:18
    secrets:
      - db-password
    environment:
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    volumes:
      - postgres_data:/var/lib/postgresql

volumes:
  postgres_data:
secrets:
  db-password:
    file: db/password.txt

Run the following command to run your application with Compose Watch.

$ docker compose watch

Any changes to the application's source files on your local machine will now be immediately reflected in the running container.

Open docker-ruby-on-rails/app/views/whales/index.html.erb in an IDE or text editor and update the Whales string by adding an exclamation mark.

-    <h1>Whales</h1>
+    <h1>Whales!</h1>

Save the changes to index.html.erb and then wait a few seconds for the application to rebuild. Go to the application again and verify that the updated text appears.

Press ctrl+c in the terminal to stop your application.

Summary

In this section, you took a look at setting up your Compose file to add a local database and persist data. You also learned how to use Compose Watch to automatically rebuild and run your container when you update your code.

Rust language-specific guide

The Rust language-specific guide teaches you how to create a containerized Rust application using Docker. In this guide, you'll learn how to:

After completing the Rust modules, you should be able to containerize your own Rust application based on the examples and instructions provided in this guide.

Start with building your first Rust image.

Build your Rust image

Prerequisites

Overview

This guide walks you through building your first Rust image. An image includes everything needed to run an application - the code or binary, runtime, dependencies, and any other file system objects required.

Get the sample application

Clone the sample application to use with this guide. Open a terminal, change directory to a directory that you want to work in, and run the following command to clone the repository:

$ git clone https://github.com/docker/docker-rust-hello && cd docker-rust-hello

Choose a base image

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

Before editing your Dockerfile, you need to choose a base image. You can use the Rust Docker Official Image,
or a Docker Hardened Image (DHI).

Docker Hardened Images (DHIs) are minimal, secure, and production-ready base images maintained by Docker.
They help reduce vulnerabilities and simplify compliance. For more details, see Docker Hardened Images.

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

Docker Hardened Images (DHIs) are publicly available and can be used directly as base images. To pull Docker Hardened Images, authenticate once with Docker:

docker login dhi.io

Use DHIs from the dhi.io registry, for example:

FROM dhi.io/rust:${RUST_VERSION}-alpine3.22-dev AS build

The following Dockerfile uses a Rust DHI as the build base image:

# Make sure RUST_VERSION matches the Rust version
ARG RUST_VERSION=1.92
ARG APP_NAME=docker-rust-hello

################################################################################
# Create a stage for building the application.
################################################################################

FROM dhi.io/rust:${RUST_VERSION}-alpine3.22-dev AS build
ARG APP_NAME
WORKDIR /app

# Install host build dependencies.
RUN apk add --no-cache clang lld musl-dev git

# Build the application.
RUN --mount=type=bind,source=src,target=src \
    --mount=type=bind,source=Cargo.toml,target=Cargo.toml \
    --mount=type=bind,source=Cargo.lock,target=Cargo.lock \
    --mount=type=cache,target=/app/target/ \
    --mount=type=cache,target=/var/cache/cargo \
    CARGO_HOME=/var/cache/cargo cargo build --locked --release && \
    cp ./target/release/$APP_NAME /bin/server

################################################################################
# Create a new stage for running the application that contains the minimal
# We use dhi.io/static for the final stage because it’s a minimal Docker Hardened Image runtime (basically “just # enough OS to run the binary”), which helps keep the image small and with a lower attack surface compared to a # # full Alpine/Debian runtime.
################################################################################

FROM dhi.io/static:20250419 AS final

# Copy the executable from the "build" stage.
COPY --from=build /bin/server /bin/

# Configure rocket to listen on all interfaces.
ENV ROCKET_ADDRESS=0.0.0.0

# Expose the port that the application listens on.
EXPOSE 8000

# What the container should run when it is started.
CMD ["/bin/server"]

{{< /tab >}} {{< tab name="Using the Docker Official Images" >}}

# Pin the Rust toolchain version used in the build stage.
ARG RUST_VERSION=1.92

# Name of the compiled binary produced by Cargo (must match Cargo.toml package name).
ARG APP_NAME=docker-rust-hello

################################################################################
# Build stage (DOI Rust image)
# This stage compiles the application.
################################################################################

FROM docker.io/library/rust:${RUST_VERSION}-alpine AS build

# Re-declare args inside the stage if you want to use them here.
ARG APP_NAME

# All build steps happen inside /app.
WORKDIR /app

# Install build dependencies needed to compile Rust crates on Alpine
RUN apk add --no-cache clang lld musl-dev git

# Build the application
RUN --mount=type=bind,source=src,target=src \
    --mount=type=bind,source=Cargo.toml,target=Cargo.toml \
    --mount=type=bind,source=Cargo.lock,target=Cargo.lock \
    --mount=type=cache,target=/app/target/ \
    --mount=type=cache,target=/var/cache/cargo \
    CARGO_HOME=/var/cache/cargo cargo build --locked --release && \
    cp ./target/release/$APP_NAME /bin/server

################################################################################
# Runtime stage (DOI Alpine image)
# This stage runs the already-compiled binary with minimal dependencies.
################################################################################

FROM docker.io/library/alpine:3.18 AS final

# Create a non-privileged user (recommended best practice)
ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser

# Drop privileges for runtime.
USER appuser

# Copy only the compiled binary from the build stage.
COPY --from=build /bin/server /bin/

# Rocket: listen on all interfaces inside the container.
ENV ROCKET_ADDRESS=0.0.0.0

# Document the port your app listens on.
EXPOSE 8000

# Start the application.
CMD ["/bin/server"]

{{< /tab >}} {{< /tabs >}}

The cache mount uses a single CARGO_HOME so concurrent builds coordinate access through Cargo's cache lock. Setting CARGO_HOME also changes where Cargo looks for global configuration and credentials. Project-level .cargo/config.toml files are unaffected.

For building an image, only the Dockerfile is necessary. Open the Dockerfile in your favorite IDE or text editor and see what it contains. To learn more about Dockerfiles, see the Dockerfile reference.

.dockerignore file

The .dockerignore file specifies patterns and paths that you don't want copied into the image in order to keep the image as small as possible. Open up the .dockerignore file in your favorite IDE or text editor to review its contents.

Build an image

Now that you’ve created the Dockerfile, you can build the image. To do this, use the docker build command. The docker build command builds Docker images from a Dockerfile and a context. A build's context is the set of files located in the specified PATH or URL. The Docker build process can access any of the files located in this context.

The build command optionally takes a --tag flag. The tag sets the name of the image and an optional tag in the format name:tag. If you don't pass a tag, Docker uses "latest" as its default tag.

Build the Docker image.

$ docker build --tag docker-rust-image-dhi .

You should see output like the following.

[+] Building 1.4s (13/13) FINISHED                                                                                                                                 docker:desktop-linux
 => [internal] load build definition from Dockerfile                                                                                                                               0.0s
 => => transferring dockerfile: 1.67kB                                                                                                                                             0.0s
 => [internal] load metadata for dhi.io/static:20250419                                                                                                                            1.1s
 => [internal] load metadata for dhi.io/rust:1.92-alpine3.22-dev                                                                                                                   1.2s
 => [auth] static:pull token for dhi.io                                                                                                                                            0.0s
 => [auth] rust:pull token for dhi.io                                                                                                                                              0.0s
 => [internal] load .dockerignore                                                                                                                                                  0.0s
 => => transferring context: 646B                                                                                                                                                  0.0s
 => [build 1/3] FROM dhi.io/rust:1.92-alpine3.22-dev@sha256:49eb72825a9e15fe48f2c4875a63c7e7f52a5b430bb52b8254b91d132aa5bf38                                                       0.0s
 => => resolve dhi.io/rust:1.92-alpine3.22-dev@sha256:49eb72825a9e15fe48f2c4875a63c7e7f52a5b430bb52b8254b91d132aa5bf38                                                             0.0s
 => [final 1/2] FROM dhi.io/static:20250419@sha256:74fc43fa240887b8159970e434244039aab0c6efaaa9cf044004cdc22aa2a34d                                                                0.0s
 => => resolve dhi.io/static:20250419@sha256:74fc43fa240887b8159970e434244039aab0c6efaaa9cf044004cdc22aa2a34d                                                                      0.0s
 => [internal] load build context                                                                                                                                                  0.0s
 => => transferring context: 117B                                                                                                                                                  0.0s
 => CACHED [build 2/3] WORKDIR /build                                                                                                                                              0.0s
 => CACHED [build 3/3] RUN --mount=type=bind,source=src,target=src     --mount=type=bind,source=Cargo.toml,target=Cargo.toml     --mount=type=bind,source=Cargo.lock,target=Cargo  0.0s
 => CACHED [final 2/2] COPY --from=build /build/target/release/docker-rust-hello /server                                                                                           0.0s
 => exporting to image                                                                                                                                                             0.1s
 => => exporting layers                                                                                                                                                            0.0s
 => => exporting manifest sha256:cc937bbdd712ef6e5445501f77e02ef8455ef64c567598786d46b7b21a4d4fa8                                                                                  0.0s
 => => exporting config sha256:077507b483af4b5e1a928e527e4bb3a4aaf0557e1eea81cd39465f564c187669                                                                                    0.0s
 => => exporting attestation manifest sha256:11b60e7608170493da1fdd88c120e2d2957f2a72a22edbc9cfbdd0dd37d21f89                                                                      0.0s
 => => exporting manifest list sha256:99a1b925a8d6ebf80e376b8a1e50cd806ec42d194479a3375e1cd9d2911b4db9                                                                             0.0s
 => => naming to docker.io/library/docker-rust-image-dhi:latest                                                                                                                    0.0s
 => => unpacking to docker.io/library/docker-rust-image-dhi:latest                                                                                                                 0.0s

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/yczk0ijw8kc5g20e8nbc8r6lj

View local images

To see a list of images you have on your local machine, you have two options. One is to use the Docker CLI and the other is to use Docker Desktop. As you are working in the terminal already, take a look at listing images using the CLI.

To list images, run the docker images command.

$ docker images
IMAGE                          ID             DISK USAGE   CONTENT SIZE   EXTRA
docker-rust-image-dhi:latest   99a1b925a8d6       11.6MB         2.45MB    U

You should see at least one image listed, including the image you just built docker-rust-image-dhi:latest.

Tag images

As mentioned earlier, an image name is made up of slash-separated name components. Name components may contain lowercase letters, digits, and separators. A separator can include a period, one or two underscores, or one or more dashes. A name component may not start or end with a separator.

An image is made up of a manifest and a list of layers. Don't worry too much about manifests and layers at this point other than a "tag" points to a combination of these artifacts. You can have multiple tags for an image. Create a second tag for the image you built and take a look at its layers.

To create a new tag for the image you built, run the following command.

$ docker tag docker-rust-image-dhi:latest docker-rust-image-dhi:v1.0.0

The docker tag command creates a new tag for an image. It doesn't create a new image. The tag points to the same image and is just another way to reference the image.

Now, run the docker images command to see a list of the local images.

$ docker images
IMAGE                          ID             DISK USAGE   CONTENT SIZE   EXTRA
docker-rust-image-dhi:latest   99a1b925a8d6       11.6MB         2.45MB    U
docker-rust-image-dhi:v1.0.0   99a1b925a8d6       11.6MB         2.45MB    U

You can see that two images start with docker-rust-image-dhi. You know they're the same image because if you take a look at the IMAGE ID column, you can see that the values are the same for the two images.

Remove the tag you just created. To do this, use the rmi command. The rmi command stands for remove image.

$ docker rmi docker-rust-image-dhi:v1.0.0
Untagged: docker-rust-image-dhi:v1.0.0

Note that the response from Docker tells you that Docker didn't remove the image, but only "untagged" it. You can check this by running the docker images command.

$ docker images
IMAGE                          ID             DISK USAGE   CONTENT SIZE   EXTRA
docker-rust-image-dhi:latest   99a1b925a8d6       11.6MB         2.45MB    U

Docker removed the image tagged with :v1.0.0, but the docker-rust-image-dhi:latest tag is available on your machine.

Run your Rust image as a container

Prerequisite

You have completed Build your Rust image and you have built an image.

Overview

A container is a normal operating system process except that Docker isolates this process so that it has its own file system, its own networking, and its own isolated process tree separate from the host.

To run an image inside of a container, you use the docker run command. The docker run command requires one parameter which is the name of the image.

Run an image

Use docker run to run the image you built in Build your Rust image.

$ docker run docker-rust-image-dhi

After running this command, you’ll notice that you weren't returned to the command prompt. This is because your application is a server that runs in a loop waiting for incoming requests without returning control back to the OS until you stop the container.

Open a new terminal then make a request to the server using the curl command.

$ curl http://localhost:8000

You should see output like the following.

curl: (7) Failed to connect to localhost port 8000 after 2236 ms: Couldn't connect to server

As you can see, your curl command failed. This means you weren't able to connect to the localhost on port 8000. This is normal because your container is running in isolation which includes networking. Stop the container and restart with port 8000 published on your local network.

To stop the container, press ctrl-c. This will return you to the terminal prompt.

To publish a port for your container, you’ll use the --publish flag (-p for short) on the docker run command. The format of the --publish command is [host port]:[container port]. So, if you wanted to expose port 8000 inside the container to port 3001 outside the container, you would pass 3001:8000 to the --publish flag.

You didn't specify a port when running the application in the container and the default is 8000. If you want your previous request going to port 8000 to work, you can map the host's port 3001 to the container's port 8000:

$ docker run --publish 3001:8000 docker-rust-image-dhi

Now, rerun the curl command. Remember to open a new terminal.

$ curl http://localhost:3001

You should see output like the following.

Hello, Docker!

Success! You were able to connect to the application running inside of your container on port 8000. Switch back to the terminal where your container is running and stop it.

Press ctrl-c to stop the container.

Run in detached mode

This is great so far, but your sample application is a web server and you don't have to be connected to the container. Docker can run your container in detached mode or in the background. To do this, you can use the --detach or -d for short. Docker starts your container the same as before but this time will "detach" from the container and return you to the terminal prompt.

$ docker run -d -p 3001:8000 docker-rust-image-dhi
3e4830e7f01304811d97dd3469d47a0c7a916a8b6c28ce0ef19c6f689a521144

Docker started your container in the background and printed the Container ID on the terminal.

Again, make sure that your container is running properly. Run the curl command again.

$ curl http://localhost:3001

You should see output like the following.

Hello, Docker!

List containers

Since you ran your container in the background, how do you know if your container is running or what other containers are running on your machine? Well, to see a list of containers running on your machine, run docker ps. This is similar to how you use the ps command in Linux to see a list of processes.

You should see output like the following.

CONTAINER ID   IMAGE                   COMMAND                  CREATED          STATUS          PORTS                                         NAMES
3e4830e7f013   docker-rust-image-dhi   "/server"                23 seconds ago   Up 22 seconds   0.0.0.0:3001->8000/tcp, [::]:3001->8000/tcp   youthful_lamport

The docker ps command provides a bunch of information about your running containers. You can see the container ID, the image running inside the container, the command that was used to start the container, when it was created, the status, ports that were exposed, and the name of the container.

You are probably wondering where the name of your container is coming from. Since you didn’t provide a name for the container when you started it, Docker generated a random name. You’ll fix this in a minute, but first you need to stop the container. To stop the container, run the docker stop command which does just that, stops the container. You need to pass the name of the container or you can use the container ID.

$ docker stop youthful_lamport
youthful_lamport

Now, rerun the docker ps command to see a list of running containers.

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

Stop, start, and name containers

You can start, stop, and restart Docker containers. When you stop a container, it's not removed, but the status is changed to stopped and the process inside the container is stopped. When you ran the docker ps command in the previous module, the default output only shows running containers. When you pass the --all or -a for short, you see all containers on your machine, irrespective of their start or stop status.

$ docker ps -a
CONTAINER ID   IMAGE                   COMMAND                  CREATED              STATUS                          PORTS                                         NAMES
3e4830e7f013   docker-rust-image-dhi   "/server"                About a minute ago   Exited (0) 28 seconds ago                                                     youthful_lamport
60009b7eaf40   docker-rust-image-dhi   "/server"                2 minutes ago        Exited (0) About a minute ago                                                 sharp_noyce
152e1d7d9eea   docker-rust-image-dhi   "/server ."              4 minutes ago        Exited (0) 2 minutes ago                                                      magical_bhabha

You should now see several containers listed. These are containers that you started and stopped but you haven't removed.

Restart the container that you just stopped. Locate the name of the container you just stopped and replace the name of the container in following restart command.

$ docker restart youthful_lamport

Now list all the containers again using the docker ps --all command.

$ docker ps --all
CONTAINER ID   IMAGE                   COMMAND                  CREATED             STATUS                         PORTS                                         NAMES
3e4830e7f013   docker-rust-image-dhi   "/server"                3 minutes ago       Up 7 seconds                   0.0.0.0:3001->8000/tcp, [::]:3001->8000/tcp   youthful_lamport
60009b7eaf40   docker-rust-image-dhi   "/server"                4 minutes ago       Exited (0) 3 minutes ago                                                     sharp_noyce
152e1d7d9eea   docker-rust-image-dhi   "/server ."              5 minutes ago       Exited (0) 4 minutes ago                                                     magical_bhabha

Notice that the container you just restarted has been started in detached mode. Also, observe the status of the container is "Up X seconds". When you restart a container, it starts with the same flags or commands that it was originally started with.

Now, stop and remove all of your containers and take a look at fixing the random naming issue. Stop the container you just started. Find the name of your running container and replace the name in the following command with the name of the container on your system.

$ docker stop youthful_lamport
youthful_lamport

Now that you have stopped all of your containers, remove them. When you remove a container, it's no longer running, nor is it in the stopped status, but the process inside the container has been stopped and the metadata for the container has been removed.

To remove a container, run the docker rm command with the container name. You can pass multiple container names to the command using a single command. Again, replace the container names in the following command with the container names from your system.

$ docker rm youthful_lamport friendly_montalcini tender_bose
youthful_lamport
sharp_noyce
magical_bhabha

Run the docker ps --all command again to see that Docker removed all containers.

Now, it's time to address the random naming issue. Standard practice is to name your containers for the simple reason that it's easier to identify what's running in the container and what application or service it's associated with.

To name a container, pass the --name flag to the docker run command.

$ docker run -d -p 3001:8000 --name docker-rust-container docker-rust-image-dhi
1aa5d46418a68705c81782a58456a4ccdb56a309cb5e6bd399478d01eaa5cdda
$ docker ps
CONTAINER ID   IMAGE                   COMMAND                  CREATED         STATUS         PORTS                                         NAMES
219b2e3c7c38   docker-rust-image-dhi   "/server"                6 seconds ago   Up 5 seconds   0.0.0.0:3001->8000/tcp, [::]:3001->8000/tcp   docker-rust-container

Now you can identify your container based on the name.

Develop your Rust application

Prerequisites

Overview

In this section, you’ll learn how to use volumes and networking in Docker. You’ll also use Docker to build your images and Docker Compose to make everything a whole lot easier.

First, you’ll take a look at running a database in a container and how you can use volumes and networking to persist your data and let your application talk with the database. Then you’ll pull everything together into a Compose file which lets you set up and run a local development environment with one command.

Run a database in a container

Instead of downloading PostgreSQL, installing, configuring, and then running the PostgreSQL database as a service, you can use the Docker Official Image for PostgreSQL and run it in a container.

Before you run PostgreSQL in a container, create a volume that Docker can manage to store your persistent data and configuration. Use the named volumes feature that Docker provides instead of using bind mounts.

Run the following command to create your volume.

$ docker volume create db-data

Now create a network that your application and database will use to talk to each other. The network is called a user-defined bridge network and gives you a nice DNS lookup service which you can use when creating your connection string.

$ docker network create postgresnet

Now you can run PostgreSQL in a container and attach to the volume and network that you created previously. Docker pulls the image from Hub and runs it for you locally. In the following command, option --mount is for starting the container with a volume. For more information, see Docker volumes.

$ docker run --rm -d --mount \
  "type=volume,src=db-data,target=/var/lib/postgresql" \
  -p 5432:5432 \
  --network postgresnet \
  --name db \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -e POSTGRES_DB=example \
  postgres:18

Now, make sure that your PostgreSQL database is running and that you can connect to it. Connect to the running PostgreSQL database inside the container.

$ docker exec -it db psql -U postgres

You should see output like the following.

psql (15.3 (Debian 15.3-1.pgdg110+1))
Type "help" for help.

postgres=#

In the previous command, you logged in to the PostgreSQL database by passing the psql command to the db container. Press ctrl-d to exit the PostgreSQL interactive terminal.

Get and run the sample application

For the sample application, you'll use a variation of the backend from the react-rust-postgres application from Awesome Compose.

  1. Clone the sample application repository using the following command.

    $ git clone https://github.com/docker/docker-rust-postgres
    
  2. In the cloned repository's directory, create a Dockerfile. This application includes a migrations directory (in addition to src) to initialize the database, so the Dockerfile includes a bind mount for that directory in the build stage.

    # syntax=docker/dockerfile:1
    
    # Comments are provided throughout this file to help you get started.
    # If you need more help, visit the Dockerfile reference guide at
    # https://docs.docker.com/reference/dockerfile/
    
    ################################################################################
    # Create a stage for building the application.
    
    ARG RUST_VERSION=1.70.0
    ARG APP_NAME=react-rust-postgres
    FROM rust:${RUST_VERSION}-slim-bookworm AS build
    ARG APP_NAME
    WORKDIR /app
    
    # Build the application.
    # Leverage a cache mount to /var/cache/cargo for downloaded dependencies
    # and a cache mount to /app/target/ for compiled dependencies which will
    # speed up subsequent builds.
    # Leverage a bind mount to the src directory to avoid having to copy the
    # source code into the container. Once built, copy the executable to an
    # output directory before the cache mounted /app/target is unmounted.
    RUN --mount=type=bind,source=src,target=src \
        --mount=type=bind,source=Cargo.toml,target=Cargo.toml \
        --mount=type=bind,source=Cargo.lock,target=Cargo.lock \
        --mount=type=cache,target=/app/target/ \
        --mount=type=cache,target=/var/cache/cargo \
        --mount=type=bind,source=migrations,target=migrations \
        <<EOF
    set -e
    CARGO_HOME=/var/cache/cargo cargo build --locked --release
    cp ./target/release/$APP_NAME /bin/server
    EOF
    
    ################################################################################
    # Create a new stage for running the application that contains the minimal
    # runtime dependencies for the application. This often uses a different base
    # image from the build stage where the necessary files are copied from the build
    # stage.
    #
    # The example below uses the Debian Bookworm image as the foundation for    running the app.
    # By specifying the "bookworm-slim" tag, it will also use whatever happens to    be the
    # most recent version of that tag when you build your Dockerfile. If
    # reproducibility is important, consider using a digest
    # (e.g.,    debian@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171).
    FROM debian:bookworm-slim AS final
    
    # Create a non-privileged user that the app will run under.
    # See https://docs.docker.com/develop/develop-images/dockerfile_best-practices/   #user
    ARG UID=10001
    RUN adduser \
        --disabled-password \
        --gecos "" \
        --home "/nonexistent" \
        --shell "/sbin/nologin" \
        --no-create-home \
        --uid "${UID}" \
        appuser
    USER appuser
    
    # Copy the executable from the "build" stage.
    COPY --from=build /bin/server /bin/
    
    # Expose the port that the application listens on.
    EXPOSE 8000
    
    # What the container should run when it is started.
    CMD ["/bin/server"]
    
  3. In the cloned repository's directory, run docker build to build the image.

    $ docker build -t rust-backend-image .
    
  4. Run docker run with the following options to run the image as a container on the same network as the database.

    $ docker run \
      --rm -d \
      --network postgresnet \
      --name docker-develop-rust-container \
      -p 3001:8000 \
      -e PG_DBNAME=example \
      -e PG_HOST=db \
      -e PG_USER=postgres \
      -e PG_PASSWORD=mysecretpassword \
      -e ADDRESS=0.0.0.0:8000 \
      -e RUST_LOG=debug \
      rust-backend-image
    
  5. Curl the application to verify that it connects to the database.

    $ curl http://localhost:3001/users
    

    You should get a response like the following.

    [{ "id": 1, "login": "root" }]
    

Use Compose to develop locally

In the cloned repository's directory, create a compose.yaml file. Using Compose, you don't have to type all the parameters to pass to the docker run command — you can declare them in the file instead.

You need to update the following items in the compose.yaml file:

The following is the updated compose.yaml file.

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker compose reference guide at
# https://docs.docker.com/reference/compose-file/

# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  server:
    build:
      context: .
      target: final
    ports:
      - 8000:8000
    environment:
      - PG_DBNAME=example
      - PG_HOST=db
      - PG_USER=postgres
      - PG_PASSWORD=mysecretpassword
      - ADDRESS=0.0.0.0:8000
      - RUST_LOG=debug
    # The commented out section below is an example of how to define a PostgreSQL
    # database that your application can use. `depends_on` tells Docker Compose to
    # start the database before your application. The `db-data` volume persists the
    # database data between container restarts. The `db-password` secret is used
    # to set the database password. You must create `db/password.txt` and add
    # a password of your choosing to it before running `docker compose up`.
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:18
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: ["CMD", "pg_isready"]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

Note that the file doesn't specify a network for those 2 services. When you use Compose, it automatically creates a network and connects the services to it. For more information see Networking in Compose.

Before you run the application using Compose, notice that this Compose file specifies a password.txt file to hold the database's password. You must create this file as it's not included in the source repository.

In the cloned repository's directory, create a new directory named db and inside that directory create a file named password.txt that contains the password for the database. Using your favorite IDE or text editor, add the following contents to the password.txt file.

mysecretpassword

If you have any other containers running from the previous sections, stop them now.

Now, run the following docker compose up command to start your application.

$ docker compose up --build

The command passes the --build flag so Docker will compile your image and then start the containers.

Now test your API endpoint. Open a new terminal then make a request to the server using the curl commands:

$ curl http://localhost:8000/users

You should receive the following response:

[{ "id": 1, "login": "root" }]

Summary

In this section, you took a look at setting up your Compose file to run your Rust application and database with a single command.

Testing an ASP.NET Core web app with Testcontainers

In this guide, you'll learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

Set up the project

Background

This guide builds on top of Microsoft's Integration tests in ASP.NET Core documentation. The original sample uses an in-memory SQLite database as the backing store for integration tests. You'll replace SQLite with a real Microsoft SQL Server instance running in a Docker container using Testcontainers.

You can find the original code sample in the dotnet/AspNetCore.Docs.Samples repository.

Clone the repository

Clone the Testcontainers guide repository and change into the project directory:

$ git clone https://github.com/testcontainers/tc-guide-testing-aspnet-core.git
$ cd tc-guide-testing-aspnet-core

Project structure

The solution contains two projects:

RazorPagesProject.sln
├── src/RazorPagesProject/              # ASP.NET Core Razor Pages app
└── tests/RazorPagesProject.Tests/      # xUnit integration tests

Application project

The application project (src/RazorPagesProject/RazorPagesProject.csproj) is a Razor Pages web app that uses Entity Framework Core with SQLite as its default database provider:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

</Project>

The ApplicationDbContext stores Message entities and provides methods to query and manage them:

public class ApplicationDbContext : IdentityDbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    public virtual DbSet<Message> Messages { get; set; }

    public async virtual Task<List<Message>> GetMessagesAsync()
    {
        return await Messages
            .OrderBy(message => message.Text)
            .AsNoTracking()
            .ToListAsync();
    }

    public async virtual Task AddMessageAsync(Message message)
    {
        await Messages.AddAsync(message);
        await SaveChangesAsync();
    }

    public async virtual Task DeleteAllMessagesAsync()
    {
        foreach (Message message in Messages)
        {
            Messages.Remove(message);
        }

        await SaveChangesAsync();
    }

    public async virtual Task DeleteMessageAsync(int id)
    {
        var message = await Messages.FindAsync(id);

        if (message != null)
        {
            Messages.Remove(message);
            await SaveChangesAsync();
        }
    }

    public void Initialize()
    {
        Messages.AddRange(GetSeedingMessages());
        SaveChanges();
    }

    public static List<Message> GetSeedingMessages()
    {
        return new List<Message>()
        {
            new Message(){ Text = "You're standing on my scarf." },
            new Message(){ Text = "Would you like a jelly baby?" },
            new Message(){ Text = "To the rational mind, nothing is inexplicable; only unexplained." }
        };
    }
}

Test project

The test project (tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj) includes xUnit, the ASP.NET Core testing infrastructure, and the Testcontainers MSSQL module:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="AngleSharp" Version="0.17.1" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="7.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>

    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.0" />

    <PackageReference Include="Testcontainers.MsSql" Version="3.0.0" />
    <PackageReference Include="xunit" Version="2.4.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\RazorPagesProject\RazorPagesProject.csproj" />
  </ItemGroup>

  <ItemGroup>
    <Content Update="xunit.runner.json">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
  </ItemGroup>

</Project>

The key dependencies are:

Existing SQLite-based test factory

The original project includes a CustomWebApplicationFactory that replaces the application's database with an in-memory SQLite instance:

public class CustomWebApplicationFactory<TProgram>
    : WebApplicationFactory<TProgram> where TProgram : class
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var dbContextDescriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                    typeof(DbContextOptions<ApplicationDbContext>));

            services.Remove(dbContextDescriptor);

            var dbConnectionDescriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                    typeof(DbConnection));

            services.Remove(dbConnectionDescriptor);

            // Create open SqliteConnection so EF won't automatically close it.
            services.AddSingleton<DbConnection>(container =>
            {
                var connection = new SqliteConnection("DataSource=:memory:");
                connection.Open();

                return connection;
            });

            services.AddDbContext<ApplicationDbContext>((container, options) =>
            {
                var connection = container.GetRequiredService<DbConnection>();
                options.UseSqlite(connection);
            });
        });

        builder.UseEnvironment("Development");
    }
}

While this approach works, SQLite has behavioral differences from the database you'd use in production. In the next section, you'll replace it with a Testcontainers-managed Microsoft SQL Server instance.

Write tests with Testcontainers

The existing tests use an in-memory SQLite database. While convenient, this doesn't match production behavior. You can replace it with a real Microsoft SQL Server instance managed by Testcontainers.

Add dependencies

Change to the test project directory and add the SQL Server Entity Framework provider and the Testcontainers MSSQL module:

$ cd tests/RazorPagesProject.Tests
$ dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 7.0.0
$ dotnet add package Testcontainers.MsSql --version 3.0.0

[!NOTE] Testcontainers for .NET offers a range of modules that follow best practice configurations.

Create the test class

Create a MsSqlTests.cs file in the IntegrationTests directory. This class manages the SQL Server container lifecycle and contains a nested test class.

using System.Data.Common;
using System.Net;
using AngleSharp.Html.Dom;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using RazorPagesProject.Data;
using RazorPagesProject.Tests.Helpers;
using Testcontainers.MsSql;
using Xunit;

namespace RazorPagesProject.Tests.IntegrationTests;

public sealed class MsSqlTests : IAsyncLifetime
{
    private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder().Build();

    public Task InitializeAsync()
    {
        return _msSqlContainer.StartAsync();
    }

    public Task DisposeAsync()
    {
        return _msSqlContainer.DisposeAsync().AsTask();
    }

    public sealed class IndexPageTests : IClassFixture<MsSqlTests>, IDisposable
    {
        private readonly WebApplicationFactory<Program> _webApplicationFactory;

        private readonly HttpClient _httpClient;

        public IndexPageTests(MsSqlTests fixture)
        {
            var clientOptions = new WebApplicationFactoryClientOptions();
            clientOptions.AllowAutoRedirect = false;

            _webApplicationFactory = new CustomWebApplicationFactory(fixture);
            _httpClient = _webApplicationFactory.CreateClient(clientOptions);
        }

        public void Dispose()
        {
            _webApplicationFactory.Dispose();
        }

        [Fact]
        public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot()
        {
            // Arrange
            var defaultPage = await _httpClient.GetAsync("/")
                .ConfigureAwait(false);

            var document = await HtmlHelpers.GetDocumentAsync(defaultPage)
                .ConfigureAwait(false);

            // Act
            var form = (IHtmlFormElement)document.QuerySelector("form[id='messages']");
            var submitButton = (IHtmlButtonElement)document.QuerySelector("button[id='deleteAllBtn']");

            var response = await _httpClient.SendAsync(form, submitButton)
                .ConfigureAwait(false);

            // Assert
            Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
            Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
            Assert.Equal("/", response.Headers.Location.OriginalString);
        }

        private sealed class CustomWebApplicationFactory : WebApplicationFactory<Program>
        {
            private readonly string _connectionString;

            public CustomWebApplicationFactory(MsSqlTests fixture)
            {
                _connectionString = fixture._msSqlContainer.GetConnectionString();
            }

            protected override void ConfigureWebHost(IWebHostBuilder builder)
            {
                builder.ConfigureServices(services =>
                {
                    services.Remove(services.SingleOrDefault(service => typeof(DbContextOptions<ApplicationDbContext>) == service.ServiceType));
                    services.Remove(services.SingleOrDefault(service => typeof(DbConnection) == service.ServiceType));
                    services.AddDbContext<ApplicationDbContext>((_, option) => option.UseSqlServer(_connectionString));
                });
            }
        }
    }
}

Understand the test structure

Container lifecycle with IAsyncLifetime

The outer MsSqlTests class implements IAsyncLifetime. xUnit calls InitializeAsync() right after creating the class instance, which starts the SQL Server container. After all tests complete, DisposeAsync() stops and removes the container.

private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder().Build();

MsSqlBuilder().Build() creates a pre-configured Microsoft SQL Server container. Testcontainers modules follow best practices, so you don't need to configure ports, passwords, or startup wait strategies yourself.

Nested test class with IClassFixture

The IndexPageTests class is nested inside MsSqlTests and implements IClassFixture<MsSqlTests>. This gives the test class access to the container's private field and creates a clean hierarchy in the test explorer.

Custom WebApplicationFactory

Instead of using the SQLite-based factory, the nested CustomWebApplicationFactory retrieves the connection string from the running SQL Server container and passes it to UseSqlServer():

private sealed class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    private readonly string _connectionString;

    public CustomWebApplicationFactory(MsSqlTests fixture)
    {
        _connectionString = fixture._msSqlContainer.GetConnectionString();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.Remove(services.SingleOrDefault(service => typeof(DbContextOptions<ApplicationDbContext>) == service.ServiceType));
            services.Remove(services.SingleOrDefault(service => typeof(DbConnection) == service.ServiceType));
            services.AddDbContext<ApplicationDbContext>((_, option) => option.UseSqlServer(_connectionString));
        });
    }
}

This factory:

  1. Removes the existing DbContextOptions<ApplicationDbContext> registration
  2. Removes the existing DbConnection registration
  3. Adds a new ApplicationDbContext configured with the SQL Server connection string from the Testcontainers-managed container

[!NOTE] The Microsoft SQL Server Docker image isn't compatible with ARM devices, such as Macs with Apple Silicon. You can use the SqlEdge module or Testcontainers Cloud as alternatives.

Run tests and next steps

Run the tests

Run the tests from the solution root:

$ dotnet test ./RazorPagesProject.sln

The first run may take longer because Docker needs to pull the Microsoft SQL Server image. On subsequent runs, the image is cached locally.

You should see xUnit discover and run the tests, including the MsSqlTests.IndexPageTests class. Testcontainers starts a SQL Server container, the tests execute against it, and the container is stopped and removed automatically after the tests finish.

Summary

By replacing SQLite with a Testcontainers-managed Microsoft SQL Server instance, the integration tests run against the same type of database used in production. This approach catches database-specific issues early, such as differences in SQL dialect, transaction behavior, or data type handling between SQLite and SQL Server.

The MsSqlTests class uses IAsyncLifetime to manage the container lifecycle, and a nested CustomWebApplicationFactory wires the container's connection string into the application's service configuration. You can apply this same pattern to any database or service that Testcontainers supports.

To learn more about Testcontainers, visit the Testcontainers overview.

Further reading

Getting started with Testcontainers for .NET

In this guide, you will learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

Create the .NET project

Set up the solution

Create a .NET solution with source and test projects:

$ dotnet new sln -o TestcontainersDemo
$ cd TestcontainersDemo
$ dotnet new classlib -o CustomerService
$ dotnet sln add ./CustomerService/CustomerService.csproj
$ dotnet new xunit -o CustomerService.Tests
$ dotnet sln add ./CustomerService.Tests/CustomerService.Tests.csproj
$ dotnet add ./CustomerService.Tests/CustomerService.Tests.csproj reference ./CustomerService/CustomerService.csproj

Add the Npgsql dependency to the source project:

$ dotnet add ./CustomerService/CustomerService.csproj package Npgsql

Implement the business logic

Create a Customer record type:

namespace Customers;

public readonly record struct Customer(long Id, string Name);

Create a DbConnectionProvider class to manage database connections:

using System.Data.Common;
using Npgsql;

namespace Customers;

public sealed class DbConnectionProvider
{
    private readonly string _connectionString;

    public DbConnectionProvider(string connectionString)
    {
        _connectionString = connectionString;
    }

    public DbConnection GetConnection()
    {
        return new NpgsqlConnection(_connectionString);
    }
}

Create the CustomerService class:

namespace Customers;

public sealed class CustomerService
{
    private readonly DbConnectionProvider _dbConnectionProvider;

    public CustomerService(DbConnectionProvider dbConnectionProvider)
    {
        _dbConnectionProvider = dbConnectionProvider;
        CreateCustomersTable();
    }

    public IEnumerable<Customer> GetCustomers()
    {
        IList<Customer> customers = new List<Customer>();

        using var connection = _dbConnectionProvider.GetConnection();
        using var command = connection.CreateCommand();
        command.CommandText = "SELECT id, name FROM customers";
        command.Connection?.Open();

        using var dataReader = command.ExecuteReader();
        while (dataReader.Read())
        {
            var id = dataReader.GetInt64(0);
            var name = dataReader.GetString(1);
            customers.Add(new Customer(id, name));
        }

        return customers;
    }

    public void Create(Customer customer)
    {
        using var connection = _dbConnectionProvider.GetConnection();
        using var command = connection.CreateCommand();

        var id = command.CreateParameter();
        id.ParameterName = "@id";
        id.Value = customer.Id;

        var name = command.CreateParameter();
        name.ParameterName = "@name";
        name.Value = customer.Name;

        command.CommandText = "INSERT INTO customers (id, name) VALUES(@id, @name)";
        command.Parameters.Add(id);
        command.Parameters.Add(name);
        command.Connection?.Open();
        command.ExecuteNonQuery();
    }

    private void CreateCustomersTable()
    {
        using var connection = _dbConnectionProvider.GetConnection();
        using var command = connection.CreateCommand();
        command.CommandText = "CREATE TABLE IF NOT EXISTS customers (id BIGINT NOT NULL, name VARCHAR NOT NULL, PRIMARY KEY (id))";
        command.Connection?.Open();
        command.ExecuteNonQuery();
    }
}

Here's what CustomerService does:

Write tests with Testcontainers

Add Testcontainers dependencies

Add the Testcontainers PostgreSQL module to the test project:

$ dotnet add ./CustomerService.Tests/CustomerService.Tests.csproj package Testcontainers.PostgreSql

Write the test

Create CustomerServiceTest.cs in the test project:

using Testcontainers.PostgreSql;

namespace Customers.Tests;

public sealed class CustomerServiceTest : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    public Task InitializeAsync()
    {
        return _postgres.StartAsync();
    }

    public Task DisposeAsync()
    {
        return _postgres.DisposeAsync().AsTask();
    }

    [Fact]
    public void ShouldReturnTwoCustomers()
    {
        // Given
        var customerService = new CustomerService(new DbConnectionProvider(_postgres.GetConnectionString()));

        // When
        customerService.Create(new Customer(1, "George"));
        customerService.Create(new Customer(2, "John"));
        var customers = customerService.GetCustomers();

        // Then
        Assert.Equal(2, customers.Count());
    }
}

Here's what the test does:

Run tests and next steps

Run the tests

Run the tests:

$ dotnet test

You can see in the output that Testcontainers pulls the Postgres Docker image from Docker Hub (if not already available locally), starts the container, and runs the test.

Writing an integration test using Testcontainers works like writing a unit test that you can run from your IDE. Your teammates can clone the project and run tests without installing Postgres on their machines.

Summary

The Testcontainers for .NET library helps you write integration tests using the same type of database (Postgres) that you use in production, instead of mocks. Because you aren't using mocks and instead talk to real services, you're free to refactor code and still verify that the application works as expected.

In addition to Postgres, Testcontainers provides dedicated modules for many SQL databases, NoSQL databases, messaging queues, and more.

To learn more about Testcontainers, visit the Testcontainers overview.

Further reading

Getting started with Testcontainers for Go

In this guide, you will learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

Create the Go project

Initialize the project

Start by creating a Go project.

$ mkdir testcontainers-go-demo
$ cd testcontainers-go-demo
$ go mod init github.com/testcontainers/testcontainers-go-demo

This guide uses the jackc/pgx PostgreSQL driver to interact with the Postgres database and the testcontainers-go Postgres module to spin up a Postgres Docker instance for testing. It also uses testify for running multiple tests as a suite and for writing assertions.

Install these dependencies:

$ go get github.com/jackc/pgx/v5
$ go get github.com/testcontainers/testcontainers-go
$ go get github.com/testcontainers/testcontainers-go/modules/postgres
$ go get github.com/stretchr/testify

Create Customer struct

Create a types.go file in the customer package and define the Customer struct to model the customer details:

package customer

type Customer struct {
	Id    int
	Name  string
	Email string
}

Create Repository

Next, create customer/repo.go, define the Repository struct, and add methods to create a customer and get a customer by email:

package customer

import (
	"context"
	"fmt"
	"os"

	"github.com/jackc/pgx/v5"
)

type Repository struct {
	conn *pgx.Conn
}

func NewRepository(ctx context.Context, connStr string) (*Repository, error) {
	conn, err := pgx.Connect(ctx, connStr)
	if err != nil {
		_, _ = fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
		return nil, err
	}
	return &Repository{
		conn: conn,
	}, nil
}

func (r Repository) CreateCustomer(ctx context.Context, customer Customer) (Customer, error) {
	err := r.conn.QueryRow(ctx,
		"INSERT INTO customers (name, email) VALUES ($1, $2) RETURNING id",
		customer.Name, customer.Email).Scan(&customer.Id)
	return customer, err
}

func (r Repository) GetCustomerByEmail(ctx context.Context, email string) (Customer, error) {
	var customer Customer
	query := "SELECT id, name, email FROM customers WHERE email = $1"
	err := r.conn.QueryRow(ctx, query, email).
		Scan(&customer.Id, &customer.Name, &customer.Email)
	if err != nil {
		return Customer{}, err
	}
	return customer, nil
}

Here's what the code does:

Write tests with Testcontainers

You have the Repository implementation ready, but for testing you need a PostgreSQL database. You can use testcontainers-go to spin up a Postgres database in a Docker container and run your tests against that database.

Set up the test database

In real applications you might use a database migration tool, but for this guide, use a script to initialize the database.

Create a testdata/init-db.sql file to create the CUSTOMERS table and insert sample data:

CREATE TABLE IF NOT EXISTS customers (id serial, name varchar(255), email varchar(255));

INSERT INTO customers(name, email) VALUES ('John', 'john@gmail.com');

Understand the testcontainers-go API

The testcontainers-go library provides the generic Container abstraction that can run any containerized service. To further simplify, testcontainers-go provides technology-specific modules that reduce boilerplate and provide a functional options pattern to construct the container instance.

For example, PostgresContainer provides WithDatabase(), WithUsername(), WithPassword(), and other functions to set various properties of Postgres containers.

Write the test

Create the customer/repo_test.go file and implement the test:

package customer

import (
	"context"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
)

func TestCustomerRepository(t *testing.T) {
	ctx := context.Background()

	ctr, err := postgres.Run(ctx,
		"postgres:16-alpine",
		postgres.WithInitScripts(filepath.Join("..", "testdata", "init-db.sql")),
		postgres.WithDatabase("test-db"),
		postgres.WithUsername("postgres"),
		postgres.WithPassword("postgres"),
		postgres.BasicWaitStrategies(),
	)
	testcontainers.CleanupContainer(t, ctr)
	require.NoError(t, err)

	connStr, err := ctr.ConnectionString(ctx, "sslmode=disable")
	require.NoError(t, err)

	customerRepo, err := NewRepository(ctx, connStr)
	require.NoError(t, err)

	c, err := customerRepo.CreateCustomer(ctx, Customer{
		Name:  "Henry",
		Email: "henry@gmail.com",
	})
	assert.NoError(t, err)
	assert.NotNil(t, c)

	customer, err := customerRepo.GetCustomerByEmail(ctx, "henry@gmail.com")
	assert.NoError(t, err)
	assert.NotNil(t, customer)
	assert.Equal(t, "Henry", customer.Name)
	assert.Equal(t, "henry@gmail.com", customer.Email)
}

Here's what the test does:

Reuse containers with test suites

In the previous section, you saw how to spin up a Postgres Docker container for a single test. But often you have multiple tests in a single file, and you may want to reuse the same Postgres Docker container for all of them.

You can use the testify suite package to implement common test setup and teardown actions.

Extract container setup

First, extract the PostgresContainer creation logic into a separate file called testhelpers/containers.go:

package testhelpers

import (
	"context"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/require"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
)

type PostgresContainer struct {
	*postgres.PostgresContainer
	ConnectionString string
}

func CreatePostgresContainer(t *testing.T, ctx context.Context) *PostgresContainer {
	t.Helper()

	ctr, err := postgres.Run(ctx,
		"postgres:16-alpine",
		postgres.WithInitScripts(filepath.Join("..", "testdata", "init-db.sql")),
		postgres.WithDatabase("test-db"),
		postgres.WithUsername("postgres"),
		postgres.WithPassword("postgres"),
		postgres.BasicWaitStrategies(),
	)
	testcontainers.CleanupContainer(t, ctr)
	require.NoError(t, err)

	connStr, err := ctr.ConnectionString(ctx, "sslmode=disable")
	require.NoError(t, err)

	return &PostgresContainer{
		PostgresContainer: ctr,
		ConnectionString:  connStr,
	}
}

In containers.go, PostgresContainer extends the testcontainers-go PostgresContainer to provide easy access to ConnectionString. The CreatePostgresContainer() function accepts *testing.T as its first parameter, calls t.Helper() so that test failures point to the caller, and uses testcontainers.CleanupContainer() to register automatic cleanup.

Write the test suite

Create customer/repo_suite_test.go and implement tests for creating a customer and getting a customer by email using the testify suite package:

package customer

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"
	"github.com/testcontainers/testcontainers-go-demo/testhelpers"
)

type CustomerRepoTestSuite struct {
	suite.Suite
	pgContainer *testhelpers.PostgresContainer
	repository  *Repository
	ctx         context.Context
}

func (suite *CustomerRepoTestSuite) SetupSuite() {
	suite.ctx = context.Background()
	suite.pgContainer = testhelpers.CreatePostgresContainer(suite.T(), suite.ctx)

	repository, err := NewRepository(suite.ctx, suite.pgContainer.ConnectionString)
	require.NoError(suite.T(), err)
	suite.repository = repository
}

func (suite *CustomerRepoTestSuite) TestCreateCustomer() {
	t := suite.T()

	customer, err := suite.repository.CreateCustomer(suite.ctx, Customer{
		Name:  "Henry",
		Email: "henry@gmail.com",
	})
	require.NoError(t, err)
	assert.NotNil(t, customer.Id)
}

func (suite *CustomerRepoTestSuite) TestGetCustomerByEmail() {
	t := suite.T()

	customer, err := suite.repository.GetCustomerByEmail(suite.ctx, "john@gmail.com")
	require.NoError(t, err)
	assert.Equal(t, "John", customer.Name)
	assert.Equal(t, "john@gmail.com", customer.Email)
}

func TestCustomerRepoTestSuite(t *testing.T) {
	suite.Run(t, new(CustomerRepoTestSuite))
}

Here's what the code does:

[!TIP] For the purpose of this guide, the tests don't reset data in the database. In practice, it's a good idea to reset the database to a known state before running each test.

Run tests and next steps

Run the tests

Run all the tests using go test ./.... Optionally add the -v flag for verbose output:

$ go test -v ./...

You should see two Postgres Docker containers start automatically: one for the suite and its two tests, and another for the initial standalone test. All tests should pass. After the tests finish, the containers are stopped and removed automatically.

Summary

The Testcontainers for Go library helps you write integration tests by using the same type of database (Postgres) that you use in production, instead of mocks. Because you aren't using mocks and instead talk to real services, you're free to refactor code and still verify that the application works as expected.

To learn more about Testcontainers, visit the Testcontainers overview.

Further reading

Getting started with Testcontainers for Java

In this guide, you will learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

Create the Java project

Set up the Maven project

Create a Java project with Maven from your preferred IDE. This guide uses Maven, but you can use Gradle if you prefer. Add the following dependencies to pom.xml:

<dependencies>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.3</version>
    </dependency>
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.5.6</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.5</version>
        </plugin>
    </plugins>
</build>

This adds the Postgres JDBC driver, logback for logging, JUnit 5 for testing, and the latest maven-surefire-plugin for JUnit 5 support.

Implement the business logic

Create a Customer record:

package com.testcontainers.demo;

public record Customer(Long id, String name) {}

Create a DBConnectionProvider class to hold JDBC connection parameters and provide a database Connection:

package com.testcontainers.demo;

import java.sql.Connection;
import java.sql.DriverManager;

class DBConnectionProvider {

  private final String url;
  private final String username;
  private final String password;

  public DBConnectionProvider(String url, String username, String password) {
    this.url = url;
    this.username = username;
    this.password = password;
  }

  Connection getConnection() {
    try {
      return DriverManager.getConnection(url, username, password);
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }
}

Create the CustomerService class:

package com.testcontainers.demo;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

public class CustomerService {

  private final DBConnectionProvider connectionProvider;

  public CustomerService(DBConnectionProvider connectionProvider) {
    this.connectionProvider = connectionProvider;
    createCustomersTableIfNotExists();
  }

  public void createCustomer(Customer customer) {
    try (Connection conn = this.connectionProvider.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        "insert into customers(id,name) values(?,?)"
      );
      pstmt.setLong(1, customer.id());
      pstmt.setString(2, customer.name());
      pstmt.execute();
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }

  public List<Customer> getAllCustomers() {
    List<Customer> customers = new ArrayList<>();

    try (Connection conn = this.connectionProvider.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        "select id,name from customers"
      );
      ResultSet rs = pstmt.executeQuery();
      while (rs.next()) {
        long id = rs.getLong("id");
        String name = rs.getString("name");
        customers.add(new Customer(id, name));
      }
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
    return customers;
  }

  private void createCustomersTableIfNotExists() {
    try (Connection conn = this.connectionProvider.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        """
        create table if not exists customers (
            id bigint not null,
            name varchar not null,
            primary key (id)
        )
        """
      );
      pstmt.execute();
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }
}

Here's what CustomerService does:

Write tests with Testcontainers

You have the CustomerService implementation ready, but for testing you need a PostgreSQL database. You can use Testcontainers to spin up a Postgres database in a Docker container and run your tests against it.

Add Testcontainers dependencies

Add the Testcontainers PostgreSQL module as a test dependency in pom.xml:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-postgresql</artifactId>
    <version>2.0.4</version>
    <scope>test</scope>
</dependency>

Since the application uses a Postgres database, the Testcontainers Postgres module provides a PostgreSQLContainer class for managing the container.

Write the test

Create CustomerServiceTest.java under src/test/java:

package com.testcontainers.demo;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.postgresql.PostgreSQLContainer;

class CustomerServiceTest {

  static PostgreSQLContainer postgres = new PostgreSQLContainer(
    "postgres:16-alpine"
  );

  CustomerService customerService;

  @BeforeAll
  static void beforeAll() {
    postgres.start();
  }

  @AfterAll
  static void afterAll() {
    postgres.stop();
  }

  @BeforeEach
  void setUp() {
    DBConnectionProvider connectionProvider = new DBConnectionProvider(
      postgres.getJdbcUrl(),
      postgres.getUsername(),
      postgres.getPassword()
    );
    customerService = new CustomerService(connectionProvider);
  }

  @Test
  void shouldGetCustomers() {
    customerService.createCustomer(new Customer(1L, "George"));
    customerService.createCustomer(new Customer(2L, "John"));

    List<Customer> customers = customerService.getAllCustomers();
    assertEquals(2, customers.size());
  }
}

Here's what the test does:

Run tests and next steps

Run the tests

Run the tests using Maven:

$ mvn test

You can see in the logs that Testcontainers pulls the Postgres Docker image from Docker Hub (if not already available locally), starts the container, and runs the test.

Writing an integration test using Testcontainers works like writing a unit test that you can run from your IDE. Your teammates can clone the project and run tests without installing Postgres on their machines.

Summary

The Testcontainers for Java library helps you write integration tests using the same type of database (Postgres) that you use in production, instead of mocks. Because you aren't using mocks and instead talk to real services, you're free to refactor code and still verify that the application works as expected.

In addition to Postgres, Testcontainers provides dedicated modules for many SQL databases, NoSQL databases, messaging queues, and more. You can use Testcontainers to run any containerized dependency for your tests.

To learn more about Testcontainers, visit the Testcontainers overview.

Further reading

Testcontainers container lifecycle management using JUnit 5

In this guide, you will learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

Create the project and business logic

Set up the project

Create a Java project with Maven and add the required dependencies:

<dependencies>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.3</version>
    </dependency>
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.5.6</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <version>2.0.4</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-postgresql</artifactId>
        <version>2.0.4</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Create the business logic

Create a Customer record:

package com.testcontainers.demo;

public record Customer(Long id, String name) {}

Create a CustomerService class with methods to create, retrieve, and delete customers:

package com.testcontainers.demo;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class CustomerService {

  private final String url;
  private final String username;
  private final String password;

  public CustomerService(String url, String username, String password) {
    this.url = url;
    this.username = username;
    this.password = password;
    createCustomersTableIfNotExists();
  }

  public void createCustomer(Customer customer) {
    try (Connection conn = this.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        "insert into customers(id,name) values(?,?)"
      );
      pstmt.setLong(1, customer.id());
      pstmt.setString(2, customer.name());
      pstmt.execute();
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }

  public List<Customer> getAllCustomers() {
    List<Customer> customers = new ArrayList<>();
    try (Connection conn = this.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        "select id,name from customers"
      );
      ResultSet rs = pstmt.executeQuery();
      while (rs.next()) {
        long id = rs.getLong("id");
        String name = rs.getString("name");
        customers.add(new Customer(id, name));
      }
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
    return customers;
  }

  public Optional<Customer> getCustomer(Long customerId) {
    try (Connection conn = this.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        "select id,name from customers where id = ?"
      );
      pstmt.setLong(1, customerId);
      ResultSet rs = pstmt.executeQuery();
      if (rs.next()) {
        long id = rs.getLong("id");
        String name = rs.getString("name");
        return Optional.of(new Customer(id, name));
      }
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
    return Optional.empty();
  }

  public void deleteAllCustomers() {
    try (Connection conn = this.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement("delete from customers");
      pstmt.execute();
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }

  private void createCustomersTableIfNotExists() {
    try (Connection conn = this.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        """
        create table if not exists customers (
            id bigint not null,
            name varchar not null,
            primary key (id)
        )
        """
      );
      pstmt.execute();
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }

  private Connection getConnection() {
    try {
      return DriverManager.getConnection(url, username, password);
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }
}

JUnit 5 lifecycle callbacks

When testing with Testcontainers, you want to start the required containers before executing any tests and remove them afterwards. You can use JUnit 5 @BeforeAll and @AfterAll lifecycle callback methods for this:

package com.testcontainers.demo;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.postgresql.PostgreSQLContainer;

class CustomerServiceWithLifeCycleCallbacksTest {

  static PostgreSQLContainer postgres = new PostgreSQLContainer(
    "postgres:16-alpine"
  );

  CustomerService customerService;

  @BeforeAll
  static void startContainers() {
    postgres.start();
  }

  @AfterAll
  static void stopContainers() {
    postgres.stop();
  }

  @BeforeEach
  void setUp() {
    customerService =
    new CustomerService(
      postgres.getJdbcUrl(),
      postgres.getUsername(),
      postgres.getPassword()
    );
    customerService.deleteAllCustomers();
  }

  @Test
  void shouldCreateCustomer() {
    customerService.createCustomer(new Customer(1L, "George"));

    Optional<Customer> customer = customerService.getCustomer(1L);
    assertTrue(customer.isPresent());
    assertEquals(1L, customer.get().id());
    assertEquals("George", customer.get().name());
  }

  @Test
  void shouldGetCustomers() {
    customerService.createCustomer(new Customer(1L, "George"));
    customerService.createCustomer(new Customer(2L, "John"));

    List<Customer> customers = customerService.getAllCustomers();
    assertEquals(2, customers.size());
  }
}

Here's what the code does:

Key observations:

JUnit 5 extension annotations

The Testcontainers library provides a JUnit 5 extension that simplifies starting and stopping containers using annotations. To use it, add the org.testcontainers:testcontainers-junit-jupiter test dependency.

package com.testcontainers.demo;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
class CustomerServiceWithJUnit5ExtensionTest {

  @Container
  static PostgreSQLContainer postgres = new PostgreSQLContainer(
    "postgres:16-alpine"
  );

  CustomerService customerService;

  @BeforeEach
  void setUp() {
    customerService =
    new CustomerService(
      postgres.getJdbcUrl(),
      postgres.getUsername(),
      postgres.getPassword()
    );
    customerService.deleteAllCustomers();
  }

  @Test
  void shouldCreateCustomer() {
    customerService.createCustomer(new Customer(1L, "George"));

    Optional<Customer> customer = customerService.getCustomer(1L);
    assertTrue(customer.isPresent());
    assertEquals(1L, customer.get().id());
    assertEquals("George", customer.get().name());
  }

  @Test
  void shouldGetCustomers() {
    customerService.createCustomer(new Customer(1L, "George"));
    customerService.createCustomer(new Customer(2L, "John"));

    List<Customer> customers = customerService.getAllCustomers();
    assertEquals(2, customers.size());
  }
}

Instead of manually starting and stopping the container in @BeforeAll and @AfterAll, the @Testcontainers annotation on the class and the @Container annotation on the field handle it automatically:

Singleton containers pattern

As the number of test classes grows, starting containers for each class adds up. The singleton containers pattern starts all required containers once in a common base class and reuses them across all integration tests.

Define the base class

Create an abstract base class that starts the containers in a static initializer:

package com.testcontainers.demo;

import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.kafka.ConfluentKafkaContainer;

public abstract class AbstractIntegrationTest {

   static PostgreSQLContainer postgres = new PostgreSQLContainer(
           "postgres:16-alpine");
   static ConfluentKafkaContainer kafka = new ConfluentKafkaContainer(
           "confluentinc/cp-kafka:7.8.0");

   static {
       postgres.start();
       kafka.start();
   }
}

The containers start once when the class loads and Testcontainers uses the Ryuk container to remove them after the JVM exits.

[!TIP] Instead of starting containers sequentially, start them in parallel using Startables.deepStart(postgres, kafka).join();

Extend the base class

Each test class inherits from the base class and reuses the same containers:

class ProductControllerTest extends AbstractIntegrationTest {

   ProductRepository productRepository;

   @BeforeEach
   void setUp() {
       productRepository = new ProductRepository(...);
       productRepository.deleteAll();
   }

   @Test
   void shouldGetAllProducts() {
       // test logic using the shared postgres container
   }
}

Avoid a common misconfiguration

A common mistake is combining singleton containers with the @Testcontainers and @Container annotations:

// DON'T DO THIS — containers will stop after each test class
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class AbstractIntegrationTest {

   @Container
   static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
           DockerImageName.parse("postgres:16-alpine"));

   @DynamicPropertySource
   static void configureProperties(DynamicPropertyRegistry registry) {
       registry.add("spring.datasource.url", postgres::getJdbcUrl);
       registry.add("spring.datasource.username", postgres::getUsername);
       registry.add("spring.datasource.password", postgres::getPassword);
   }
}

The @Testcontainers extension stops containers at the end of each test class. Subsequent test classes reuse the cached Spring context, but the containers are already stopped — causing connection failures.

Instead, use a static initializer or @BeforeAll to start the containers, without the @Testcontainers and @Container annotations.

Summary

Further reading

Replace H2 with a real database for testing

In this guide, you will learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

The problem with H2 for testing

A common practice is to use lightweight databases like H2 or HSQL as in-memory databases for testing while using PostgreSQL, MySQL, or Oracle in production. This approach has significant drawbacks:

Example: PostgreSQL-specific syntax

Consider implementing an "upsert" — insert a product only if it doesn't already exist. In PostgreSQL, you can use:

INSERT INTO products(id, code, name) VALUES(?,?,?) ON CONFLICT DO NOTHING;

This query doesn't work with H2 by default:

Caused by: org.h2.jdbc.JdbcSQLException: Syntax error in SQL statement
"INSERT INTO products (id, code, name) VALUES (?, ?, ?) ON[*] CONFLICT DO NOTHING";

You can run H2 in PostgreSQL compatibility mode, but not all features are supported. The inverse is also true — H2 supports ROWNUM() which PostgreSQL doesn't.

Testing with a different database than production means you can't trust your test results and must verify after deployment, defeating the purpose of automated tests.

The Spring Boot test using H2

A typical H2-based test looks like this:

@DataJpaTest
class ProductRepositoryTest {

   @Autowired
   ProductRepository productRepository;

   @Test
   @Sql("classpath:/sql/seed-data.sql")
   void shouldGetAllProducts() {
       List<Product> products = productRepository.findAll();
       assertEquals(2, products.size());
   }
}

Spring Boot uses H2 automatically when it's on the classpath. The test passes, but it doesn't catch PostgreSQL-specific issues.

Replace H2 with the Testcontainers JDBC URL

Replacing H2 with a real PostgreSQL database requires two test properties:

@DataJpaTest
@TestPropertySource(properties = {
  "spring.test.database.replace=none",
  "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db"
})
class ProductRepositoryWithJdbcUrlTest {

  @Autowired
  ProductRepository productRepository;

  @Test
  @Sql("classpath:/sql/seed-data.sql")
  void shouldGetAllProducts() {
    List<Product> products = productRepository.findAll();
    assertEquals(2, products.size());
  }
}

That's it — two properties and your tests run against a real PostgreSQL database.

How the special JDBC URL works

A standard PostgreSQL JDBC URL looks like:

jdbc:postgresql://localhost:5432/postgres

The Testcontainers special JDBC URL inserts tc: after jdbc::

jdbc:tc:postgresql:///db

The hostname, port, and database name are ignored — Testcontainers manages them automatically. You can specify the Docker image tag after the database name:

jdbc:tc:postgresql:16-alpine:///db

This creates a container from the postgres:16-alpine image.

Initialize the database with a script

Pass TC_INITSCRIPT to run an SQL script when the container starts:

jdbc:tc:postgresql:16-alpine:///db?TC_INITSCRIPT=sql/init-db.sql

Testcontainers runs the script automatically. For production applications, use a database migration tool like Flyway or Liquibase instead.

The special JDBC URL also works for MySQL, MariaDB, PostGIS, YugabyteDB, CockroachDB, and other databases with Testcontainers JDBC support.

Testing JdbcTemplate-based repositories

The same approach works for JdbcTemplate-based repositories. Use @JdbcTest instead of @DataJpaTest:

@JdbcTest
@TestPropertySource(properties = {
  "spring.test.database.replace=none",
  "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db?TC_INITSCRIPT=sql/init-db.sql"
})
class JdbcProductRepositoryTest {

  @Autowired
  private JdbcTemplate jdbcTemplate;

  private JdbcProductRepository productRepository;

  @BeforeEach
  void setUp() {
    productRepository = new JdbcProductRepository(jdbcTemplate);
  }

  @Test
  @Sql("/sql/seed-data.sql")
  void shouldGetAllProducts() {
    List<Product> products = productRepository.getAllProducts();
    assertEquals(2, products.size());
  }
}

Use the JUnit 5 extension for more control

If the special JDBC URL doesn't meet your needs, or you need more control over container creation (for example, to copy initialization scripts), use the Testcontainers JUnit 5 extension:

@DataJpaTest
@TestPropertySource(properties = {
    "spring.test.database.replace=none"
})
@Testcontainers
class ProductRepositoryTest {

  @Container
  static PostgreSQLContainer postgres =
    new PostgreSQLContainer("postgres:16-alpine")
      .withCopyFileToContainer(
        MountableFile.forClasspathResource("sql/init-db.sql"),
        "/docker-entrypoint-initdb.d/init-db.sql");

  @DynamicPropertySource
  static void configureProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
  }

  @Autowired
  ProductRepository productRepository;

  @Test
  @Sql("/sql/seed-data.sql")
  void shouldGetAllProducts() {
    List<Product> products = productRepository.findAll();
    assertEquals(2, products.size());
  }

  @Test
  @Sql("/sql/seed-data.sql")
  void shouldNotCreateAProductWithDuplicateCode() {
    Product product = new Product(3L, "p101", "Test Product");
    productRepository.createProductIfNotExists(product);
    Optional<Product> optionalProduct = productRepository.findById(
      product.getId()
    );
    assertThat(optionalProduct).isEmpty();
  }
}

This approach:

Summary

Further reading

Configuration of services running in a container

In this guide, you will learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

Copy files into containers

Sometimes you need to initialize a container by placing files in a specific location. For example, PostgreSQL runs SQL scripts from /docker-entrypoint-initdb.d/ when the container starts.

Create the initialization script

Create src/test/resources/init-db.sql:

create table customers (
     id bigint not null,
     name varchar not null,
     primary key (id)
);

Copy the file into the container

Use withCopyFileToContainer() to copy the SQL script into the container's init directory:

package com.testcontainers.demo;

import static org.junit.jupiter.api.Assertions.assertFalse;

import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.MountableFile;

@Testcontainers
class CustomerServiceTest {

  @Container
  static PostgreSQLContainer postgres = new PostgreSQLContainer(
    "postgres:16-alpine"
  )
    .withCopyFileToContainer(
      MountableFile.forClasspathResource("init-db.sql"),
      "/docker-entrypoint-initdb.d/"
    );

  CustomerService customerService;

  @BeforeEach
  void setUp() {
    customerService =
    new CustomerService(
      postgres.getJdbcUrl(),
      postgres.getUsername(),
      postgres.getPassword()
    );
  }

  @Test
  void shouldGetCustomers() {
    customerService.createCustomer(new Customer(1L, "George"));
    customerService.createCustomer(new Customer(2L, "John"));

    List<Customer> customers = customerService.getAllCustomers();
    assertFalse(customers.isEmpty());
  }
}

The withCopyFileToContainer(MountableFile, String) method copies init-db.sql from the classpath into /docker-entrypoint-initdb.d/ inside the container. PostgreSQL executes scripts in that directory automatically at startup.

You can also copy files from any path on the host:

.withCopyFileToContainer(
    MountableFile.forHostPath("/host/path/to/init-db.sql"),
    "/docker-entrypoint-initdb.d/"
);

Execute commands inside containers

Some Docker containers provide CLI tools for performing actions. You can use container.execInContainer(String...) to run any available command inside a running container.

Example: Create an S3 bucket in LocalStack

The LocalStack module emulates AWS services. To test S3 file uploads, create a bucket before running tests:

package com.testcontainers.demo;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3;

import java.io.IOException;
import java.net.URI;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.Bucket;

@Testcontainers
class LocalStackTest {

  static final String bucketName = "mybucket";

  @Container
  static LocalStackContainer localStack = new LocalStackContainer(
    DockerImageName.parse("localstack/localstack:3.4.0")
  );

  @BeforeAll
  static void beforeAll() throws IOException, InterruptedException {
    localStack.execInContainer("awslocal", "s3", "mb", "s3://" + bucketName);

    org.testcontainers.containers.Container.ExecResult execResult =
      localStack.execInContainer("awslocal", "s3", "ls");
    String stdout = execResult.getStdout();
    int exitCode = execResult.getExitCode();
    assertTrue(stdout.contains(bucketName));
    assertEquals(0, exitCode);
  }

  @Test
  void shouldListBuckets() {
    URI s3Endpoint = localStack.getEndpointOverride(S3);
    StaticCredentialsProvider credentialsProvider =
      StaticCredentialsProvider.create(
        AwsBasicCredentials.create(
          localStack.getAccessKey(),
          localStack.getSecretKey()
        )
      );
    S3Client s3 = S3Client
      .builder()
      .endpointOverride(s3Endpoint)
      .credentialsProvider(credentialsProvider)
      .region(Region.of(localStack.getRegion()))
      .build();

    List<String> s3Buckets = s3
      .listBuckets()
      .buckets()
      .stream()
      .map(Bucket::name)
      .toList();

    assertTrue(s3Buckets.contains(bucketName));
  }
}

The execInContainer("awslocal", "s3", "mb", "s3://mybucket") call runs the awslocal CLI tool (provided by the LocalStack image) to create an S3 bucket.

You can capture the output and exit code from any command:

Container.ExecResult execResult =
    localStack.execInContainer("awslocal", "s3", "ls");
String stdout = execResult.getStdout();
int exitCode = execResult.getExitCode();

[!NOTE] The withCopyFileToContainer() and execInContainer() methods are inherited from GenericContainer, so they're available for all Testcontainers modules.

Summary

Further reading

Testing a Spring Boot REST API with Testcontainers

In this guide, you will learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

Create the Spring Boot project

Set up the project

Create a Spring Boot project from Spring Initializr by selecting the Spring Web, Spring Data JPA, PostgreSQL Driver, and Testcontainers starters.

Alternatively, clone the guide repository.

The key dependencies in pom.xml are:

<properties>
    <java.version>17</java.version>
    <testcontainers.version>2.0.4</testcontainers.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Using the Testcontainers BOM (Bill of Materials) is recommended so that you don't have to repeat the version for every Testcontainers module dependency.

Create the JPA entity

Create Customer.java:

package com.testcontainers.demo;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "customers")
class Customer {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false)
  private String name;

  @Column(nullable = false, unique = true)
  private String email;

  public Customer() {}

  public Customer(Long id, String name, String email) {
    this.id = id;
    this.name = name;
    this.email = email;
  }

  public Long getId() {
    return id;
  }

  public void setId(Long id) {
    this.id = id;
  }

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }

  public String getEmail() {
    return email;
  }

  public void setEmail(String email) {
    this.email = email;
  }
}

Create the Spring Data JPA repository

package com.testcontainers.demo;

import org.springframework.data.jpa.repository.JpaRepository;

interface CustomerRepository extends JpaRepository<Customer, Long> {}

Add the schema creation script

Create src/main/resources/schema.sql:

create table if not exists customers (
    id bigserial not null,
    name varchar not null,
    email varchar not null,
    primary key (id),
    UNIQUE (email)
);

Enable schema initialization in src/main/resources/application.properties:

spring.sql.init.mode=always

Create the REST API endpoint

Create CustomerController.java:

package com.testcontainers.demo;

import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class CustomerController {

  private final CustomerRepository repo;

  CustomerController(CustomerRepository repo) {
    this.repo = repo;
  }

  @GetMapping("/api/customers")
  List<Customer> getAll() {
    return repo.findAll();
  }
}

Write tests with Testcontainers

To test the REST API, you need a running Postgres database and a started Spring context. Testcontainers spins up Postgres in a Docker container and @DynamicPropertySource connects it to Spring.

Write the test

Create CustomerControllerTest.java:

package com.testcontainers.demo;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasSize;

import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.postgresql.PostgreSQLContainer;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CustomerControllerTest {

  @LocalServerPort
  private Integer port;

  static PostgreSQLContainer postgres = new PostgreSQLContainer(
    "postgres:16-alpine"
  );

  @BeforeAll
  static void beforeAll() {
    postgres.start();
  }

  @AfterAll
  static void afterAll() {
    postgres.stop();
  }

  @DynamicPropertySource
  static void configureProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
  }

  @Autowired
  CustomerRepository customerRepository;

  @BeforeEach
  void setUp() {
    RestAssured.baseURI = "http://localhost:" + port;
    customerRepository.deleteAll();
  }

  @Test
  void shouldGetAllCustomers() {
    List<Customer> customers = List.of(
      new Customer(null, "John", "john@mail.com"),
      new Customer(null, "Dennis", "dennis@mail.com")
    );
    customerRepository.saveAll(customers);

    given()
      .contentType(ContentType.JSON)
      .when()
      .get("/api/customers")
      .then()
      .statusCode(200)
      .body(".", hasSize(2));
  }
}

Here's what the test does:

Run tests and next steps

Run the tests

$ ./mvnw test

Or with Gradle:

$ ./gradlew test

You should see the Postgres Docker container start and all tests pass. After the tests finish, the container stops and is removed automatically.

Summary

The Testcontainers library helps you write integration tests by using the same type of database (Postgres) that you use in production, instead of mocks or in-memory databases. Because you test against real services, you're free to refactor code and still verify that the application works as expected.

To learn more about Testcontainers, visit the Testcontainers overview.

Further reading

Getting started with Testcontainers for Node.js

In this guide, you will learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

Create the Node.js project

Initialize the project

Create a new Node.js project:

$ npm init -y

Add pg, jest, and @testcontainers/postgresql as dependencies:

$ npm install pg --save
$ npm install jest @testcontainers/postgresql --save-dev

Implement the customer repository

Create src/customer-repository.js with functions to manage customers in PostgreSQL:

async function createCustomerTable(client) {
  const sql =
    "CREATE TABLE IF NOT EXISTS customers (id INT NOT NULL, name VARCHAR NOT NULL, PRIMARY KEY (id))";
  await client.query(sql);
}

async function createCustomer(client, customer) {
  const sql = "INSERT INTO customers (id, name) VALUES($1, $2)";
  await client.query(sql, [customer.id, customer.name]);
}

async function getCustomers(client) {
  const sql = "SELECT * FROM customers";
  const result = await client.query(sql);
  return result.rows;
}

module.exports = { createCustomerTable, createCustomer, getCustomers };

The module provides three functions:

Write tests with Testcontainers

Create src/customer-repository.test.js with the test:

const { Client } = require("pg");
const { PostgreSqlContainer } = require("@testcontainers/postgresql");
const {
  createCustomerTable,
  createCustomer,
  getCustomers,
} = require("./customer-repository");

describe("Customer Repository", () => {
  jest.setTimeout(60000);

  let postgresContainer;
  let postgresClient;

  beforeAll(async () => {
    postgresContainer = await new PostgreSqlContainer().start();
    postgresClient = new Client({
      connectionString: postgresContainer.getConnectionUri(),
    });
    await postgresClient.connect();
    await createCustomerTable(postgresClient);
  });

  afterAll(async () => {
    await postgresClient.end();
    await postgresContainer.stop();
  });

  it("should create and return multiple customers", async () => {
    const customer1 = { id: 1, name: "John Doe" };
    const customer2 = { id: 2, name: "Jane Doe" };

    await createCustomer(postgresClient, customer1);
    await createCustomer(postgresClient, customer2);

    const customers = await getCustomers(postgresClient);
    expect(customers).toEqual([customer1, customer2]);
  });
});

Here's what the test does:

The test timeout is set to 60 seconds to allow time for the container to start on the first run (when the Docker image needs to be pulled).

Run tests and next steps

Run the tests

Add the test script to package.json if it isn't there already:

{
  "scripts": {
    "test": "jest"
  }
}

Then run the tests:

$ npm test

You should see output like:

 PASS  src/customer-repository.test.js
  Customer Repository
    ✓ should create and return multiple customers (5 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

To see what Testcontainers is doing under the hood — which containers it starts, what versions it uses — set the DEBUG environment variable:

$ DEBUG=testcontainers* npm test

Summary

The Testcontainers for Node.js library helps you write integration tests using the same type of database (Postgres) that you use in production, instead of mocks. Because you aren't using mocks and instead talk to real services, you're free to refactor code and still verify that the application works as expected.

In addition to PostgreSQL, Testcontainers provides dedicated modules for many SQL databases, NoSQL databases, messaging queues, and more.

To learn more about Testcontainers, visit the Testcontainers overview.

Further reading

Getting started with Testcontainers for Python

In this guide, you will learn how to:

Prerequisites

[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.

Create the Python project

Initialize the project

Start by creating a Python project with a virtual environment:

$ mkdir tc-python-demo
$ cd tc-python-demo
$ python3 -m venv venv
$ source venv/bin/activate

This guide uses psycopg3 to interact with the Postgres database, pytest for testing, and testcontainers-python for running a PostgreSQL database in a container.

Install the dependencies:

$ pip install "psycopg[binary]" pytest testcontainers[postgres]
$ pip freeze > requirements.txt

The pip freeze command generates a requirements.txt file so that others can install the same package versions using pip install -r requirements.txt.

Create the database helper

Create a db/connection.py file with a function to get a database connection:

import os

import psycopg


def get_connection():
    host = os.getenv("DB_HOST", "localhost")
    port = os.getenv("DB_PORT", "5432")
    username = os.getenv("DB_USERNAME", "postgres")
    password = os.getenv("DB_PASSWORD", "postgres")
    database = os.getenv("DB_NAME", "postgres")
    return psycopg.connect(f"host={host} dbname={database} user={username} password={password} port={port}")

Instead of hard-coding the database connection parameters, the function uses environment variables. This makes it possible to run the application in different environments without changing code.

Create the business logic

Create a customers/customers.py file and define the Customer class:

class Customer:
    def __init__(self, cust_id, name, email):
        self.id = cust_id
        self.name = name
        self.email = email

    def __str__(self):
        return f"Customer({self.id}, {self.name}, {self.email})"

Add a create_table() function to create the customers table:

from db.connection import get_connection


def create_table():
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute("""
                CREATE TABLE customers (
                    id serial PRIMARY KEY,
                    name varchar not null,
                    email varchar not null unique)
                """)
            conn.commit()

The function obtains a database connection using get_connection() and creates the customers table. The with statement automatically closes the connection when done.

Add the remaining CRUD functions:

def create_customer(name, email):
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO customers (name, email) VALUES (%s, %s)", (name, email))
            conn.commit()


def get_all_customers() -> list[Customer]:
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT * FROM customers")
            return [Customer(cid, name, email) for cid, name, email in cur]


def get_customer_by_email(email) -> Customer:
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT id, name, email FROM customers WHERE email = %s", (email,))
            (cid, name, email) = cur.fetchone()
            return Customer(cid, name, email)


def delete_all_customers():
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute("DELETE FROM customers")
            conn.commit()

[!NOTE] To keep it straightforward for this guide, each function creates a new connection. In a real-world application, use a connection pool to reuse connections.

Write tests with Testcontainers

You'll create a PostgreSQL container using Testcontainers and use it for all the tests. Before each test, you'll delete all customer records so that tests run with a clean database.

Set up pytest fixtures

This guide uses pytest fixtures for setup and teardown logic. A recommended approach is to use finalizers to guarantee cleanup runs even if setup fails:

@pytest.fixture
def setup(request):
    # setup code

    def cleanup():
        # teardown code

    request.addfinalizer(cleanup)
    return some_value

Create the test file

Create a tests/__init__.py file with empty content to enable pytest auto-discovery.

Then create tests/test_customers.py with the fixtures:

import os
import pytest
from testcontainers.postgres import PostgresContainer

from customers import customers

postgres = PostgresContainer("postgres:16-alpine")


@pytest.fixture(scope="module", autouse=True)
def setup(request):
    postgres.start()

    def remove_container():
        postgres.stop()

    request.addfinalizer(remove_container)
    os.environ["DB_CONN"] = postgres.get_connection_url()
    os.environ["DB_HOST"] = postgres.get_container_host_ip()
    os.environ["DB_PORT"] = str(postgres.get_exposed_port(5432))
    os.environ["DB_USERNAME"] = postgres.username
    os.environ["DB_PASSWORD"] = postgres.password
    os.environ["DB_NAME"] = postgres.dbname
    customers.create_table()


@pytest.fixture(scope="function", autouse=True)
def setup_data():
    customers.delete_all_customers()

Here's what the fixtures do:

Write the tests

Add the test functions to the same file:

def test_get_all_customers():
    customers.create_customer("Siva", "siva@gmail.com")
    customers.create_customer("James", "james@gmail.com")
    customers_list = customers.get_all_customers()
    assert len(customers_list) == 2


def test_get_customer_by_email():
    customers.create_customer("John", "john@gmail.com")
    customer = customers.get_customer_by_email("john@gmail.com")
    assert customer.name == "John"
    assert customer.email == "john@gmail.com"

Because setup_data deletes all records before each test, the tests can run in any order.

Run tests and next steps

Run the tests

Run the tests using pytest:

$ pytest -v

You should see output similar to:

============================= test session starts ==============================
platform linux -- Python 3.13.x, pytest-9.x.x
collected 2 items

tests/test_customers.py::test_get_all_customers PASSED                   [ 50%]
tests/test_customers.py::test_get_customer_by_email PASSED               [100%]

============================== 2 passed in 1.90s ===============================

The tests run against a real PostgreSQL database instead of mocks, which gives more confidence in the implementation.

Summary

The Testcontainers for Python library helps you write integration tests using the same type of database (Postgres) that you use in production, instead of mocks. Because you aren't using mocks and instead talk to real services, you're free to refactor code and still verify that the application works as expected.

In addition to PostgreSQL, Testcontainers for Python provides modules for many SQL databases, NoSQL databases, messaging queues, and more. You can use Testcontainers to run any containerized dependency for your tests.

To learn more about Testcontainers, visit the Testcontainers overview.

Further reading

HTTP routing with Traefik

During local development, it’s quite common to need to run multiple HTTP services. You might have both an API and a frontend app, a WireMock service to mock data endpoints, or a database visualizer (such as phpMyAdmin or pgAdmin). In many development setups, these services are exposed on different ports, which then requires you to remember what’s on what port but can also introduce other problems (such as CORS).

A reverse proxy can dramatically simplify this setup by being the single exposed service and then routing requests to the appropriate service based on the request URL (either by path or hostname). Traefik is a modern, cloud-native reverse proxy and load balancer that makes developing and deploying multi-service applications easier. This guide will show you how to use Traefik with Docker to enhance your development environment.

In this guide, you will learn how to:

  1. Start Traefik with Docker
  2. Configure routing rules to split traffic between two containers
  3. Use Traefik in a containerized development environment
  4. Use Traefik to send requests to non-containerized workloads

Prerequisites

The following prerequisites are required to follow along with this how-to guide:

Using Traefik with Docker

One of the unique features of Traefik is its ability to be configured in many ways. When using the Docker provider, Traefik gets its configuration from other running containers using labels. Traefik will watch engine events (for container starts and stops), extract the labels, and update its configuration.

While there are many Traefik-monitored labels, the two most common will be:

Let’s do a quick demo of starting Traefik and then configuring two additional containers to be accessible using different hostnames.

  1. In order for two containers to be able to communicate with each other, they need to be on the same network. Create a network named traefik-demo using the docker network create command:

    $ docker network create traefik-demo
    
  2. Start a Traefik container using one of the following methods. These commands exposes Traefik on port 80, mounts the Docker socket (which is used to monitor containers to update configuration), and passes the --providers.docker argument to configure Traefik to use the Docker provider.

    {{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

    Docker Hardened Images (DHI) for Traefik are available on Docker Hub. If you haven't authenticated yet, first run:

    $ docker login dhi.io
    

    Then start a container using the Hardened image:

    $ docker run -d --network=traefik-demo \
      -p 80:80 \
      -v /var/run/docker.sock:/var/run/docker.sock \
      dhi.io/traefik:3.6.2 \
      --providers.docker
    

    {{< /tab >}}

    {{< tab name="Using the official image" >}}

    You can also use the official image from Docker Hub:

    $ docker run -d --network=traefik-demo \
      -p 80:80 \
      -v /var/run/docker.sock:/var/run/docker.sock \
      traefik:v3.6.2 \
      --providers.docker
    

    {{< /tab >}} {{< /tabs >}}

  3. Now, start a simple Nginx container and define the labels Traefik is watching for to configure the HTTP routing. Note that the Nginx container is not exposing any ports.

    {{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

    Docker Hardened Images (DHI) for Nginx are available on Nginx DHI image. If you haven't authenticated yet, first run:

    $ docker login dhi.io
    
    $ docker run -d --network=traefik-demo \
      --label 'traefik.http.routers.nginx.rule=Host(`nginx.localhost`)' \
      dhi.io/nginx:1.29.3
    

    {{< /tab >}}

    {{< tab name="Using the official image" >}}

    You can also run the official Nginx image as follows:

    $ docker run -d --network=traefik-demo \
      --label 'traefik.http.routers.nginx.rule=Host(`nginx.localhost`)' \
      nginx:1.29.3
    

    {{< /tab >}} {{< /tabs >}}

    Once the container starts, open your browser to http://nginx.localhost to see the app (all Chromium-based browsers route *.localhost requests locally with no additional setup).

  4. Start a second application that will use a different hostname.

    $ docker run -d --network=traefik-demo --label 'traefik.http.routers.welcome.rule=Host(`welcome.localhost`)' docker/welcome-to-docker
    

    Once the container starts, open your browser to http://welcome.localhost. You should see a “Welcome to Docker” website.

Using Traefik in development

Now that you’ve experienced Traefik, it’s time to try using it in a development environment. In this example, you will use a sample application that has a split frontend and backend. This app stack has the following configuration:

  1. All requests to /api to go to the API service
  2. All other requests to localhost go to the frontend client
  3. Since the app uses MySQL, db.localhost should provide phpMyAdmin to make it easy to access the database during development

Architecture diagram showing Traefik routing requests to other containers based on the path of the request

The application can be accessed on GitHub at dockersamples/easy-http-routing-with-traefik.

  1. In the compose.yaml file, Traefik is using the following configuration:

    {{< tabs >}} {{< tab name="Using DHI image" >}}

    services:
      proxy:
        image: dhi.io/traefik:3.6.2
        command: --providers.docker
        ports:
          - 80:80
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock
    

    {{< /tab >}}

    {{< tab name="Using official image" >}}

    services:
      proxy:
        image: traefik:v3.6.2
        command: --providers.docker
        ports:
          - 80:80
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock
    

    {{< /tab >}} {{< /tabs >}}

    Note that this is essentially the same configuration as used earlier, but now in a Compose syntax.

  2. The client service has the following configuration, which will start the container and provide it with the labels to receive requests at localhost.

    {{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}

    Docker Hardened Images (DHI) for Nginx are available on Nginx DHI image.

    If you haven't authenticated yet, first run:

    $ docker login dhi.io
    

    You can use it as your base image as shown following:

    services:
      # …
      client:
        image: dhi.io/nginx:1.29.3-alpine3.21
        volumes:
          - "./client:/usr/share/nginx/html"
        labels:
          traefik.http.routers.client.rule: "Host(`localhost`)"
    

    {{< /tab >}}

    {{< tab name="Using the official image" >}}

    services:
      # …
      client:
        image: nginx:1.29.3-alpine3.22
        volumes:
          - "./client:/usr/share/nginx/html"
        labels:
          traefik.http.routers.client.rule: "Host(`localhost`)"
    

    {{< /tab >}} {{< /tabs >}}

  3. The api service has a similar configuration, but you’ll notice the routing rule has two conditions - the host must be “localhost” and the URL path must have a prefix of “/api”. Since this rule is more specific, Traefik will evaluate it first compared to the client rule.

    services:
      # …
      api:
        build: ./dev/api
        volumes:
          - "./api:/var/www/html/api"
        labels:
          traefik.http.routers.api.rule: "Host(`localhost`) && PathPrefix(`/api`)"
    
  4. And finally, the phpmyadmin service is configured to receive requests for the hostname “db.localhost”. The service also has environment variables defined to automatically log in, making it a little easier to get into the app.

    services:
      # …
      phpmyadmin:
        image: phpmyadmin:5.2.1
        labels:
          traefik.http.routers.db.rule: "Host(`db.localhost`)"
        environment:
          PMA_USER: root
          PMA_PASSWORD: password
    
  5. Before starting the stack, stop the Nginx container if it is still running.

    And that’s it. Now, you only need to spin up the Compose stack with a docker compose up and all of the services and applications will be ready for development.

Sending traffic to non-containerized workloads

In some situations, you may want to forward requests to applications not running in containers. In the following architecture diagram, the same application from before is used, but the API and React apps are now running natively on the host machine.

An architecture diagram showing several components and the routing between them. Traefik is able to send requests to both non-containerized and containerized workloads

To accomplish this, Traefik will need to use another method to configure itself. The File provider lets you define the routing rules in a YAML document. Here is an example file:

http:
  routers:
    native-api:
      rule: "Host(`localhost`) && PathPrefix(`/api`)"
      service: native-api
    native-client:
      rule: "Host(`localhost`)"
      service: native-client

  services:
    native-api:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:3000/"
    native-client:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:5173/"

This configuration indicates that requests that for localhost/api will be forwarded to a service named native-api, which then forwards the request to http://host.docker.internal:3000. The hostname host.docker.internal is a name that Docker Desktop provides to send requests to the host machine.

With this file, the only change is to the Compose configuration for Traefik. There are specifically two things that have changed:

  1. The configuration file is mounted into the Traefik container (the exact destination path is up to you)
  2. The command is updated to add the file provider and point to the location of the configuration file

{{< tabs >}} {{< tab name="Using DHI image" >}}

services:
  proxy:
    image: dhi.io/traefik:3.6.2
    command: --providers.docker --providers.file.filename=/config/traefik-config.yaml --api.insecure
    ports:
      - 80:80
      - 8080:8080
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./dev/traefik-config.yaml:/config/traefik-config.yaml

{{< /tab >}}

{{< tab name="Using official image" >}}

services:
  proxy:
    image: traefik:v3.6.2
    command: --providers.docker --providers.file.filename=/config/traefik-config.yaml --api.insecure
    ports:
      - 80:80
      - 8080:8080
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./dev/traefik-config.yaml:/config/traefik-config.yaml

{{< /tab >}} {{< /tabs >}}

Starting the example app

To run the example app that forwards requests from Traefik to native-running apps, use the following steps:

  1. If you have the Compose stack still running, stop it with the following command:

    $ docker compose down
    
  2. Start the application using the provided compose-native.yaml file:

    $ docker compose -f compose-native.yaml up
    

    Opening http://localhost will return a 502 Bad Gateway because the other apps aren’t running yet.

  3. Start the API by running the following steps:

    cd api
    yarn install
    yarn dev
    
  4. Start the frontend by running the following steps in a new terminal (starting from the root of the project):

    cd client
    yarn install
    yarn dev
    
  5. Open the app at http://localhost. You should see an app that fetches a message from http://localhost/api/messages. You can also open http://db.localhost to view or adjust the available messages directly from the Mongo database. Traefik will ensure the requests are properly routed to the correct container or application.

  6. When you’re done, run docker compose down to stop the containers and stop the Yarn apps by hitting ctrl+c.

Recap

Running multiple services doesn’t have to require tricky port configuration and a good memory. With tools like Traefik, it’s easy to launch the services you need and easily access them - whether they’re for the app itself (such as the frontend and backend) or for additional development tooling (such as phpMyAdmin).

Vue.js language-specific guide

The Vue.js language-specific guide shows you how to containerize an Vue.js application using Docker, following best practices for creating efficient, production-ready containers.

Vue.js is a progressive and flexible framework for building modern, interactive web applications. However, as applications scale, managing dependencies, environments, and deployments can become complex. Docker simplifies these challenges by providing a consistent, isolated environment for both development and production.

Acknowledgment

Docker extends its sincere gratitude to Kristiyan Velkov for authoring this guide. As a Docker Captain and highly skilled Front-end engineer, Kristiyan brings exceptional expertise in modern web development, Docker, and DevOps. His hands-on approach and clear, actionable guidance make this guide an essential resource for developers aiming to build, optimize, and secure Vue.js applications with Docker.


What will you learn?

In this guide, you will learn how to:

You'll start by containerizing an existing Vue.js application and work your way up to production-level deployments.


Prerequisites

Before you begin, ensure you have a working knowledge of:

Once you've completed the Vue.js getting started modules, you’ll be fully prepared to containerize your own Vue.js application using the detailed examples and best practices outlined in this guide.

Containerize an Vue.js Application

Prerequisites

Before you begin, make sure the following tools are installed and available on your system:

New to Docker?
Start with the Docker basics guide to get familiar with key concepts like images, containers, and Dockerfiles.


Overview

This guide walks you through the complete process of containerizing an Vue.js application with Docker. You’ll learn how to create a production-ready Docker image using best practices that improve performance, security, scalability, and deployment efficiency.

By the end of this guide, you will:


Get the sample application

Clone the sample application to use with this guide. Open a terminal, navigate to the directory where you want to work, and run the following command to clone the git repository:

$ git clone https://github.com/kristiyan-velkov/docker-vuejs-sample

Build the Docker image

Vue.js is a front-end framework that compiles into static assets, so the Dockerfile is customized to align with how Vue.js applications are built and efficiently served in a production environment.

[!TIP]

Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and .dockerignore tailored to your application.

Step 1: Create the Dockerfile

Before creating a Dockerfile, you need to choose a base image. You can either use the Node.js Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog.

Choosing DHI offers the advantage of a production-ready image that is lightweight and secure. For more information, see Docker Hardened Images.

[!IMPORTANT] This guide uses a stable Node.js LTS image tag that is considered secure when the guide is written. Because new releases and security patches are published regularly, the tag shown here may no longer be the safest option when you follow the guide. Always review the latest available image tags and select a secure, up-to-date version before building or deploying your application.

Official Node.js Docker Images: https://hub.docker.com/_/node

{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}} Docker Hardened Images (DHIs) are available for Node.js in the Docker Hardened Images catalog. Docker Hardened Images are freely available to everyone with no subscription required. You can pull and use them like any other Docker image after signing in to the DHI registry. For more information, see the DHI quickstart guide.

  1. Sign in to the DHI registry:

    $ docker login dhi.io
    
  2. Pull the Node.js DHI (check the catalog for available versions):

    $ docker pull dhi.io/node:24-alpine3.22-dev
    
  3. Pull the Nginx DHI (check the catalog for available versions):

    $ docker pull dhi.io/nginx:1.28.0-alpine3.21-dev
    

In the following Dockerfile, the FROM instructions use dhi.io/node:24-alpine3.22-dev and dhi.io/nginx:1.28.0-alpine3.21-dev as the base images.

# =========================================
# Stage 1: Build the Vue.js Application
# =========================================
# Use a lightweight DHI Node.js image for building
FROM dhi.io/node:24-alpine3.22-dev AS builder

# Set the working directory inside the container
WORKDIR /app

# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json package-lock.json* ./

# Install project dependencies using npm ci (ensures a clean, reproducible install)
RUN --mount=type=cache,target=/root/.npm npm ci

# Copy the rest of the application source code into the container
COPY . .

# Build the Vue.js application
RUN npm run build

# =========================================
# Stage 2: Prepare Nginx to Serve Static Files
# =========================================

FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner

# Copy custom Nginx config
COPY nginx.conf /etc/nginx/nginx.conf

# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html

# Use a built-in non-root user for security best practices
USER nginx

# Expose port 8080 to allow HTTP traffic
# Note: The default Nginx container now listens on port 8080 instead of 80
EXPOSE 8080

# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]

{{< /tab >}} {{< tab name="Using the Docker Official Image" >}}

Create a file named Dockerfile with the following contents:

# =========================================
# Stage 1: Build the Vue.js Application
# =========================================
ARG NODE_VERSION=24.12.0-alpine
ARG NGINX_VERSION=alpine3.22

# Use a lightweight Node.js image for building (customizable via ARG)
FROM node:${NODE_VERSION} AS builder

# Set the working directory inside the container
WORKDIR /app

# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json package-lock.json* ./

# Install project dependencies using npm ci (ensures a clean, reproducible install)
RUN --mount=type=cache,target=/root/.npm npm ci

# Copy the rest of the application source code into the container
COPY . .

# Build the Vue.js application
RUN npm run build

# =========================================
# Stage 2: Prepare Nginx to Serve Static Files
# =========================================

FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runner

# Copy custom Nginx config
COPY nginx.conf /etc/nginx/nginx.conf

# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html

# Use a built-in non-root user for security best practices
USER nginx

# Expose port 8080 to allow HTTP traffic
# Note: The default Nginx container now listens on port 8080 instead of 80
EXPOSE 8080

# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]

[!NOTE] We are using nginx-unprivileged instead of the standard Nginx image to follow security best practices. Running as a non-root user in the final image:

{{< /tab >}} {{< /tabs >}}

Step 2: Create the compose.yaml file

Create a file named compose.yaml with the following contents:

services:
  server:
    build:
      context: .
    ports:
      - 8080:8080

Step 3: Create the .dockerignore file

The .dockerignore file plays a crucial role in optimizing your Docker image by specifying which files and directories should be excluded from the build context.

[!NOTE] This helps:

To learn more, visit the .dockerignore reference.

Create a file named .dockerignore with the following contents:

# -------------------------------
# Dependency directories
# -------------------------------
node_modules/

# -------------------------------
# Production and build outputs
# -------------------------------
dist/
out/
build/
public/build/

# -------------------------------
# Vite, VuePress, and cache dirs
# -------------------------------
.vite/
.vitepress/
.cache/
.tmp/

# -------------------------------
# Test output and coverage
# -------------------------------
coverage/
reports/
jest/
cypress/
cypress/screenshots/
cypress/videos/

# -------------------------------
# Environment and config files
# -------------------------------
*.env*
!.env.production    # Keep production env if needed
*.local
*.log

# -------------------------------
# TypeScript artifacts
# -------------------------------
*.tsbuildinfo

# -------------------------------
# Editor and IDE config
# -------------------------------
.vscode/
.idea/
*.swp

# -------------------------------
# System files
# -------------------------------
.DS_Store
Thumbs.db

# -------------------------------
# Lockfiles (optional)
# -------------------------------
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# -------------------------------
# Git files
# -------------------------------
.git/
.gitignore

# -------------------------------
# Docker-related files
# -------------------------------
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.override.yml

Step 4: Create the nginx.conf file

To serve your Vue.js application efficiently inside the container, you’ll configure Nginx with a custom setup. This configuration is optimized for performance, browser caching, gzip compression, and support for client-side routing.

Create a file named nginx.conf in the root of your project directory, and add the following content:

[!NOTE] To learn more about configuring Nginx, see the official Nginx documentation.

worker_processes auto;
pid /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    charset       utf-8;

    access_log    off;
    error_log     /dev/stderr warn;

    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;
    keepalive_requests 1000;

    gzip on;
    gzip_comp_level 6;
    gzip_proxied any;
    gzip_min_length 256;
    gzip_vary on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;

    server {
        listen       8080;
        server_name  localhost;

        root   /usr/share/nginx/html;
        index  index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|map)$ {
            expires 1y;
            access_log off;
            add_header Cache-Control "public, immutable";
            add_header X-Content-Type-Options nosniff;
        }

        location /assets/ {
            expires 1y;
            add_header Cache-Control "public, immutable";
            add_header X-Content-Type-Options nosniff;
        }

        error_page 404 /index.html;
    }
}

Step 5: Build the Vue.js application image

With your custom configuration in place, you're now ready to build the Docker image for your Vue.js application.

The updated setup includes:

After completing the previous steps, your project directory should now contain the following files:

├── docker-vuejs-sample/
│ ├── Dockerfile
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf

Now that your Dockerfile is configured, you can build the Docker image for your Vue.js application.

[!NOTE] The docker build command packages your application into an image using the instructions in the Dockerfile. It includes all necessary files from the current directory (called the build context).

Run the following command from the root of your project:

$ docker build --tag docker-vuejs-sample .

What this command does:

Step 6: View local images

After building your Docker image, you can check which images are available on your local machine using either the Docker CLI or Docker Desktop. Since you're already working in the terminal, let's use the Docker CLI.

To list all locally available Docker images, run the following command:

$ docker images

Example Output:

REPOSITORY                TAG               IMAGE ID       CREATED         SIZE
docker-vuejs-sample       latest            8c9c199179d4   14 seconds ago   76.2MB

This output provides key details about your images:

If the build was successful, you should see docker-vuejs-sample image listed.


Run the containerized application

In the previous step, you created a Dockerfile for your Vue.js application and built a Docker image using the docker build command. Now it’s time to run that image in a container and verify that your application works as expected.

Inside the docker-vuejs-sample directory, run the following command in a terminal.

$ docker compose up --build

Open a browser and view the application at http://localhost:8080. You should see a simple Vue.js web application.

Press ctrl+c in the terminal to stop your application.

Run the application in the background

You can run the application detached from the terminal by adding the -d option. Inside the docker-vuejs-sample directory, run the following command in a terminal.

$ docker compose up --build -d

Open a browser and view the application at http://localhost:8080. You should see your Vue.js application running in the browser.

To confirm that the container is running, use docker ps command:

$ docker ps

This will list all active containers along with their ports, names, and status. Look for a container exposing port 8080.

Example Output:

CONTAINER ID   IMAGE                          COMMAND                  CREATED             STATUS             PORTS                    NAMES
37a1fa85e4b0   docker-vuejs-sample-server     "nginx -c /etc/nginx…"   About a minute ago  Up About a minute  0.0.0.0:8080->8080/tcp   docker-vuejs-sample-server-1

To stop the application, run:

$ docker compose down

[!NOTE] For more information about Compose commands, see the Compose CLI reference.


Use containers for Vue.js development

Prerequisites

Complete Containerize Vue.js application.


Overview

In this section, you'll set up both production and development environments for your Vue.js application using Docker Compose. This approach streamlines your workflow—delivering a lightweight, static site via Nginx in production, and providing a fast, live-reloading dev server with Compose Watch for efficient local development.

You’ll learn how to:


Automatically update services (development mode)

Leverage Compose Watch to enable real-time file synchronization between your local machine and the containerized Vue.js development environment. This powerful feature eliminates the need to manually rebuild or restart containers, providing a fast, seamless, and efficient development workflow.

With Compose Watch, your code updates are instantly reflected inside the container—perfect for rapid testing, debugging, and live previewing changes.

Step 1: Create a development Dockerfile

Create a file named Dockerfile.dev in your project root with the following content:

# =========================================
# Stage 1: Develop the Vue.js Application
# =========================================
ARG NODE_VERSION=24.12.0-alpine

# Use a lightweight Node.js image for development
FROM node:${NODE_VERSION} AS dev

# Set environment variable to indicate development mode
ENV NODE_ENV=development

# Set the working directory inside the container
WORKDIR /app

# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json package-lock.json* ./

# Install project dependencies
RUN --mount=type=cache,target=/root/.npm npm install

# Copy the rest of the application source code into the container
COPY . .

# Change ownership of the application directory to the node user
RUN chown -R node:node /app

# Switch to the node user
USER node

# Expose the port used by the Vite development server
EXPOSE 5173

# Use a default command, can be overridden in Docker compose.yml file
CMD [ "npm", "run", "dev", "--", "--host" ]

This file sets up a lightweight development environment for your Vue.js application using the dev server.

Step 2: Update your compose.yaml file

Open your compose.yaml file and define two services: one for production (vuejs-prod) and one for development (vuejs-dev).

Here’s an example configuration for an Vue.js application:

services:
  vuejs-prod:
    build:
      context: .
      dockerfile: Dockerfile
    image: docker-vuejs-sample
    ports:
      - "8080:8080"

  vuejs-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "5173:5173"
    develop:
      watch:
        - path: ./src
          target: /app/src
          action: sync
        - path: ./package.json
          target: /app/package.json
          action: restart
        - path: ./vite.config.js
          target: /app/vite.config.js
          action: restart

[!NOTE] For more details, see the official guide: Use Compose Watch.

After completing the previous steps, your project directory should now contain the following files:

├── docker-vuejs-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf

Step 4: Start Compose Watch

Run the following command from the project root to start the container in watch mode

$ docker compose watch vuejs-dev

Step 5: Test Compose Watch with Vue.js

To confirm that Compose Watch is functioning correctly:

  1. Open the src/App.vue file in your text editor.

  2. Locate the following line:

    <HelloWorld msg="You did it!" />
    
  3. Change it to:

    <HelloWorld msg="Hello from Docker Compose Watch" />
    
  4. Save the file.

  5. Open your browser at http://localhost:5173.

You should see the updated text appear instantly, without needing to rebuild the container manually. This confirms that file watching and automatic synchronization are working as expected.


Run vue.js tests in a container

Prerequisites

Complete all the previous sections of this guide, starting with Containerize Vue.js application.

Overview

Testing is a critical part of the development process. In this section, you'll learn how to:

You’ll use Vitest — a blazing fast test runner designed for Vite — together with @vue/test-utils to write unit tests that validate your component logic, props, events, and reactive behavior.

This setup ensures your Vue.js components are tested in an environment that mirrors how users actually interact with your application.


Run tests during development

docker-vuejs-sample application includes a sample test file at location:

$ src/components/__tests__/HelloWorld.spec.ts

This test uses Vitest and Vue Test Utils to verify the behavior of the HelloWorld component.


Step 1: Update compose.yaml

Add a new service named vuejs-test to your compose.yaml file. This service allows you to run your test suite in an isolated containerized environment.

services:
  vuejs-prod:
    build:
      context: .
      dockerfile: Dockerfile
    image: docker-vuejs-sample
    ports:
      - "8080:8080"

  vuejs-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "5173:5173"
    develop:
      watch:
        - action: sync
          path: .
          target: /app

  vuejs-test:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: ["npm", "run", "test:unit"]

The vuejs-test service reuses the same Dockerfile.dev used for development and overrides the default command to run tests with npm run test. This setup ensures a consistent test environment that matches your local development configuration.

After completing the previous steps, your project directory should contain the following files:

├── docker-vuejs-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf

Step 2: Run the tests

To execute your test suite inside the container, run the following command from your project root:

$ docker compose run --rm vuejs-test

This command will:

You should see output similar to the following:

Test Files: 1 passed (1)
Tests:      1 passed (1)
Start at:   16:50:55
Duration:   718ms

[!NOTE] For more information about Compose commands, see the Compose CLI reference.


Summary

In this section, you learned how to run unit tests for your Vue.js application inside a Docker container using Vitest and Docker Compose.

What you accomplished:


Explore official references and best practices to sharpen your Docker testing workflow:

Mocking API services in development and testing with WireMock

During local development and testing, it's quite common to encounter situations where your app is dependent on the remote APIs. Network issues, rate limits, or even downtime of the API provider can halt your progress. This can significantly hinder your productivity and make testing more challenging. This is where WireMock comes into play.

WireMock is an open-source tool that helps developers to create a mock server that simulates the behavior of real APIs, providing a controlled environment for development and testing.

Imagine you have both an API and a frontend app, and you want to test how the frontend interacts with the API. Using WireMock, you can set up a mock server to simulate the API's responses, allowing you to test the frontend behavior without relying on the actual API. This can be particularly helpful when the API is still under development or when you want to test different scenarios without affecting the actual API. WireMock supports both HTTP and HTTPS protocols and can simulate various response scenarios, including delays, errors, and different HTTP status codes.

In this guide, you'll learn how to:

Using WireMock with Docker

The official Docker image for WireMock provides a convenient way to deploy and manage WireMock instances. WireMock is available for various CPU architectures, including amd64, armv7, and armv8, ensuring compatibility with different devices and platforms. You can learn more about WireMock standalone on the WireMock docs site.

Prerequisites

The following prerequisites are required to follow along with this how-to guide:

Launching WireMock

Launch a quick demo of WireMock by using the following steps:

  1. Clone the GitHub repository locally.

    $ git clone https://github.com/dockersamples/wiremock-node-docker
    
  2. Navigate to the wiremock-endpoint directory

    $ cd wiremock-node-docker/
    

    WireMock acts as the mock API that your backend will communicate with to retrieve data. The mock API responses have already been created for you in the mappings directory.

  3. Start the Compose stack by running the following command at the root of the cloned project directory

    $ docker compose up -d
    

    After a moment, the application will be up and running.

    Diagram showing the WireMock container running on Docker Desktop

    You can check the logs by selecting the wiremock-node-docker container:

    Diagram showing the logs of WireMock container running on Docker Desktop

  4. Test the Mock API.

    $ curl http://localhost:8080/api/v1/getWeather\?city\=Bengaluru
    

    It will return the following canned response with mock data:

    {"city":"Bengaluru","temperature":27.1,"conditions":"Mostly cloudy","forecasts":[{"date":"2024-09-02T07:00:00+05:30","temperature":83,"conditions":"Partly sunny w/ t-storms"},{"date":"2024-09-03T07:00:00+05:30","temperature":83,"conditions":"Thunderstorms"},{"date":"2024-09-04T07:00:00+05:30","temperature":83,"conditions":"Intermittent clouds"},{"date":"2024-09-05T07:00:00+05:30","temperature":82,"conditions":"Dreary"},{"date":"2024-09-06T07:00:00+05:30","temperature":82,"conditions":"Dreary"}]}
    

    With WireMock, you define canned responses using mapping files. For this request, the mock data is defined in the JSON file at wiremock-endpoint/mappings/getWeather/getWeatherBengaluru.json.

    For more information about stubbing canned responses, refer to the WireMock documentation.

Using WireMock in development

Now that you have tried WireMock, let’s use it in development and testing. In this example, you will use a sample application that has a Node.js backend. This app stack has the following configuration:

Diagram showing the architecture of WireMock in development

Use mock data in local development

Let’s set up a Node app to send requests to the WireMock container instead of the actual AccuWeather API.

Prerequisite

Follow the steps to setup a non-containerized Node application:

  1. Navigate to the accuweather-api directory

    Make sure you're in the directory where your package.json file is located.

  2. Set the environment variable.

    Open .env file placed under accuweather-api/ directory. Remove the old entries and ensure that it just contains the following single line.

    API_ENDPOINT_BASE=http://localhost:8080
    

    This will tell your Node.js application to use the WireMock server for API calls.

  3. Examine the Application Entry Point

    • The main file for the application is index.js, located in the accuweather-api/src/api directory.
    • This file starts the getWeather.js module, which is essential for your Node.js application. It uses the dotenv package to load environment variables from the.env file.
    • Based on the value of API_ENDPOINT_BASE, the application routes requests either to the WireMock server (http://localhost:8080) or the AccuWeather API. In this setup, it uses the WireMock server.
    • The code ensures that the ACCUWEATHER_API_KEY is required only if the application is not using WireMock, enhancing efficiency and avoiding errors.
    require("dotenv").config();
    
    const express = require("express");
    const axios = require("axios");
    
    const router = express.Router();
    const API_ENDPOINT_BASE = process.env.API_ENDPOINT_BASE;
    const API_KEY = process.env.ACCUWEATHER_API_KEY;
    
    console.log('API_ENDPOINT_BASE:', API_ENDPOINT_BASE);  // Log after it's defined
    console.log('ACCUWEATHER_API_KEY is set:', !!API_KEY); // Log boolean instead of actual key
    
    if (!API_ENDPOINT_BASE) {
      throw new Error("API_ENDPOINT_BASE is not defined in environment variables");
    }
    
    // Only check for API key if not using WireMock
    if (API_ENDPOINT_BASE !== 'http://localhost:8080' && !API_KEY) {
      throw new Error("ACCUWEATHER_API_KEY is not defined in environment variables");
    }
    // Function to fetch the location key for the city
    async function fetchLocationKey(townName) {
      const { data: locationData } = await
    axios.get(`${API_ENDPOINT_BASE}/locations/v1/cities/search`, {
        params: { q: townName, details: false, apikey: API_KEY },
      });
      return locationData[0]?.Key;
    }
    
  4. Start the Node server

    Before you start the Node server, ensure that you have already installed the node packages listed in the package.json file by running npm install.

    npm install 
    npm run start
    

    You should see the following output:

    > express-api-starter@1.2.0 start
    > node src/index.js
    
    API_ENDPOINT_BASE: http://localhost:8080
    ..
    Listening: http://localhost:5001
    

    The output indicates that your Node application has successfully started. Keep this terminal window open.

  5. Test the Mocked API

    Open a new terminal window and run the following command to test the mocked API:

    $ curl "http://localhost:5001/api/v1/getWeather?city=Bengaluru"
    

    You should see the following output:

    {"city":"Bengaluru","temperature":27.1,"conditions":"Mostly cloudy","forecasts":[{"date":"2024-09-02T07:00:00+05:30","temperature":83,"conditions":"Partly sunny w/ t-storms"},{"date":"2024-09-03T07:00:00+05:30","temperature":83,"conditions":"Thunderstorms"},{"date":"2024-09-04T07:00:00+05:30","temperature":83,"conditions":"Intermittent clouds"},{"date":"2024-09-05T07:00:00+05:30","temperature":82,"conditions":"Dreary"},{"date":"2024-09-06T07:00:00+05:30","temperature":82,"conditions":"Dreary"}]}%
    

    This indicates that your Node.js application is now successfully routing requests to the WireMock container and receiving the mocked responses

    You might have noticed that you’re trying to use http://localhost:5001 as the URL instead of port 8080. This is because your Node.js application is running on port 5001, and it's routing requests to the WireMock container that's listening on port 8080.

    [!TIP] Before you proceed to the next step, ensure that you stop the node application service.

Use a live API in production to fetch real-time weather data from AccuWeather

To enhance your Node.js application with real-time weather data, you can seamlessly integrate the AccuWeather API. This section of the guide will walk you through the steps involved in setting up a non-containerized Node.js application and fetching weather information directly from the AccuWeather API.

  1. Create an AccuWeather API Key

    Sign up for a free AccuWeather developer account athttps://developer.accuweather.com/. Within your account, create a new app by selecting MY APPS on the top navigation menu to get your unique API key.

    Diagram showing the AccuWeather Dashboard

    AccuWeather API is a web API that provides real-time weather data and forecasts. Developers can use this API to integrate weather information into their applications, websites, or other projects.

  2. Change directory to accuweather-api

    $ cd accuweather-api
    
  3. Set your AccuWeather API key using the .env file:

    [!TIP] To prevent conflicts, ensure that any existing environment variables named API_ENDPOINT_BASE or ACCUWEATHER_API_KEY are removed before modifying the .env file.

    Run the following command on your terminal:

    unset API_ENDPOINT_BASE
    unset ACCUWEATHER_API_KEY
    

    It’s time to set the environment variables in the .env file:

    ACCUWEATHER_API_KEY=XXXXXX
    API_ENDPOINT_BASE=http://dataservice.accuweather.com
    

    Make sure to populate ACCUWEATHER_API_KEY with the correct value.

  4. Install the dependencies

    Run the following command to install the required packages:

    $ npm install
    

    This will install all the packages listed in your package.json file. These packages are essential for the project to function correctly.

    If you encounter any warnings related to deprecated packages, you can ignore them for now for this demonstration.

  5. Assuming that you don’t have a pre-existing Node server running on your system, go ahead and start the Node server by running the following command:

    $ npm run start
    

    You should see the following output:

    > express-api-starter@1.2.0 start
    > node src/index.js
    
    API_ENDPOINT_BASE: http://dataservice.accuweather.com
    ACCUWEATHER_API_KEY is set: true 
    Listening: http://localhost:5001
    

    Keep this terminal window open.

  6. Run the curl command to send a GET request to the server URL.

    In the new terminal window, enter the following command:

    $ curl "http://localhost:5000/api/v1/getWeather?city=Bengaluru"
    

    By running the command, you're essentially telling your local server to provide you with weather data for a city named Bengaluru. The request is specifically targeting the /api/v1/getWeather endpoint, and you're providing the query parameter city=Bengaluru. Once you execute the command, the server processes this request, fetches the data and returns it as a response, which curl will display in your terminal.

    When fetching data from the external AccuWeather API, you're interacting with live data that reflects the latest weather conditions.

Recap

This guide has walked you through setting up WireMock using Docker. You’ve learned how to create stubs to simulate API endpoints, allowing you to develop and test your application without relying on external services. By using WireMock, you can create reliable and consistent test environments, reproduce edge cases, and speed up your development workflow.

Using Docker with Zscaler

In many corporate environments, network traffic is intercepted and monitored using HTTPS proxies, such as Zscaler. While Zscaler ensures security compliance and network control, it can cause issues for developers using Docker, particularly during build processes, where SSL certificate validation errors might occur. This guide outlines how to configure Docker containers and builds to properly handle Zscaler's custom certificates, ensuring smooth operation in monitored environments.

The role of certificates in Docker

When Docker builds or runs containers, it often needs to fetch resources from the internet—whether it's pulling a base image from a registry, downloading dependencies, or communicating with external services. In a proxied environment, Zscaler intercepts HTTPS traffic and replaces the remote server's certificate with its own. However, Docker doesn't trust this Zscaler certificate by default, leading to SSL errors.

x509: certificate signed by unknown authority

These errors occur because Docker cannot verify the validity of the certificate presented by Zscaler. To avoid this, you must configure Docker to trust Zscaler's certificate.

Configure Zscaler proxy for Docker Desktop

Depending on how Zscaler is deployed, you may need to configure Docker Desktop proxy settings manually to use the Zscaler proxy.

If you're using Zscaler as a system-level proxy via the Zscaler Client Connector, all traffic on the device is automatically routed through Zscaler, so Docker Desktop uses the Zscaler proxy automatically with no additional configuration necessary.

If you are not using Zscaler as a system-level proxy, manually configure proxy settings in Docker Desktop. Set up proxy settings for all clients in the organization using Settings Management, or edit proxy configuration in the Docker Desktop GUI under Settings > Resources > Proxies.

Install root certificates in Docker images

To enable containers to use and trust the Zscaler proxy, embed the certificate in the image and configure the image's trust store. Installing certificates at image build time is the preferred approach, as it removes the need for configuration during startup and provides an auditable, consistent environment.

Obtaining the root certificate

The easiest way to obtain the root certificate is to export it from a machine where an administrator has already installed it. You can use either a web browser or the system's certificate management service (for example, Windows Certificate Store).

Example: Exporting the certificate using Google Chrome

  1. In Google Chrome, navigate to chrome://certificate-manager/.
  2. Under Local certificates, select View imported certificates.
  3. Find the Zscaler root certificate, often labeled Zscaler Root CA.
  4. Open the certificate details and select Export.
  5. Save the certificate in ASCII PEM format.
  6. Open the exported file in a text editor to confirm it includes -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----.

When you have obtained the certificate, store it in an accessible repository, such as JFrog Artifactory or a Git repository. Alternatively, use generic storage like AWS S3.

Building with the certificate

To install these certificates when building images, copy the certificate into the build container and update the trust store. An example Dockerfile looks like this:

FROM debian:bookworm
COPY zscaler-root-ca.crt /usr/local/share/ca-certificates/zscaler-root-ca.crt
RUN apt-get update && \
    apt-get install -y ca-certificates && \
    update-ca-certificates

Here, zscaler-root-ca.crt is the root certificate, located at the root of the build context (often within the application's Git repository).

If you use an artifact repository, you can fetch the certificate directly using the ADD instruction. You can also use the --checksum flag to verify that the content digest of the certificate is correct.

FROM debian:bookworm
ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2945d1036d1dc68d \
    https://artifacts.example/certs/zscaler-root-ca.crt /usr/local/share/ca-certificates/zscaler-root-ca.crt
RUN apt-get update && \
    apt-get install -y ca-certificates && \
    update-ca-certificates

Using multi-stage builds

For multi-stage builds where certificates are needed in the final runtime image, ensure the certificate installation occurs in the final stage.

FROM debian:bookworm AS build
WORKDIR /build
RUN apt-get update && apt-get install -y \
    build-essential \
    cmake \
    curl \
    git
RUN --mount=target=. cmake -B output/

FROM debian:bookworm-slim AS final
ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2945d1036d1dc68d \
    https://artifacts.example/certs/zscaler-root-ca.crt /usr/local/share/ca-certificates/zscaler-root-ca.crt
RUN apt-get update && \
    apt-get install -y ca-certificates && \
    update-ca-certificates
WORKDIR /app
COPY --from=build /build/output/bin .
ENTRYPOINT ["/app/bin"]

Conclusion

Embedding the Zscaler root certificate directly into your Docker images ensures that containers run smoothly within Zscaler-proxied environments. By using this approach, you reduce potential runtime errors and create a consistent, auditable configuration that allows for smooth Docker operations within a monitored network.