Guides

Build and run agentic AI applications with Docker
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:
Models: These are your GPTs, CodeLlamas, Mistrals. They're doing the
reasoning, writing, and planning. They're the engine behind the intelligence.
Agent: This is where the logic lives. Agents take a goal, break it down, and
figure out how to get it done. They orchestrate everything. They talk to the
UI, the tools, the model, and the gateway.
MCP gateway: This is what links your agents to the outside world, including
APIs, tools, and services. It provides a standard way for agents to call
capabilities via the Model Context Protocol (MCP).
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.
This guide walks you through the core components of agentic development and
shows how Docker ties them all together with the following tools:
Docker Model Runner lets you run LLMs
locally with simple command and OpenAI-compatible APIs.
Docker MCP Catalog and
Toolkit helps you discover
and securely run external tools, like APIs and databases, using the Model
Context Protocol (MCP).
Docker MCP Gateway lets you orchestrate and manage MCP servers.
Docker Compose is the tool that ties it all
together, letting you define and run multi-container applications with a
single file.
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:
Install Docker Desktop 4.43 or later
Enable Docker Model Runner
At least the following hardware specifications:
VRAM: 3.5 GB
Storage: 2.31 GB
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:
VRAM: 3.5 GB
Storage: 2.31 GB
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:
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.
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.
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:
The 
adk service, which is the web application that runs the agentic AI
application. This service talks to the MCP gateway and model.
The 
mcp-gateway service, which is the MCP gateway that connects the app
to external tools and services.
The 
models block, which defines the model to use with the application.
When you examine the 
compose.yaml file, you'll notice two notable elements for the model:
A service‑level 
models block in the 
adk service
A top-level 
models block
These two blocks together let Docker Compose automatically start and connect
your ADK web app to the specified LLM.
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.
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:
MODEL_RUNNER_URL: Injected by Compose (via the service-level 
models:
block) to point at your Docker Model Runner HTTP endpoint.
MODEL_RUNNER_MODEL: Injected by Compose to select which model to launch in
Model Runner.
OPENAI_API_KEY: If you define an 
openai-api-key secret in your Compose
file, Compose will mount it at 
/run/secrets/openai-api-key. The entrypoint
script reads that file and exports it as 
OPENAI_API_KEY, causing the app to
use hosted OpenAI instead of Model Runner.
OPENAI_BASE_URL: When no real key is present, this is set to
MODEL_RUNNER_URL so the ADK's OpenAI-compatible client sends requests to
Docker Model Runner.
OPENAI_MODEL_NAME: When falling back to Model Runner, the model is prefixed
with 
openai/ so the client picks up the right model alias.
Together, these variables let the same ADK web server code seamlessly target either:
Hosted OpenAI: if you supply 
OPENAI_API_KEY (and optionally 
OPENAI_MODEL_NAME)
Model Runner: by remapping 
MODEL_RUNNER_URL and 
MODEL_RUNNER_MODEL into the OpenAI client’s expected variables
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:
Critic: Verifies factual claims using the toolset, such as DuckDuckGo.
Reviser: Edits answers based on the verification verdicts provided by the Critic.
Auditor: A higher-level agent that sequences the
Critic and Reviser. It acts as the entry point, evaluating LLM-generated
answers, verifying them, and refining the final output.
All of the application's behavior is defined in Python under the 
agents/
directory. Here's a breakdown of the notable files:
agents/agent.py: Defines the Auditor, a SequentialAgent that chains together
the Critic and Reviser agents. This agent is the main entry point of the
application and is responsible for auditing LLM-generated content using
real-world verification tools.
agents/sub_agents/critic/agent.py: Defines the Critic agent. It loads the
language model (via Docker Model Runner), sets the agent’s name and behavior,
and connects to MCP tools (like DuckDuckGo).
agents/sub_agents/critic/prompt.py: Contains the Critic prompt, which
instructs the agent to extract and verify claims using external tools.
agents/sub_agents/critic/tools.py: Defines the MCP toolset configuration,
including parsing 
mcp/ strings, creating tool connections, and handling MCP
gateway communication.
agents/sub_agents/reviser/agent.py: Defines the Reviser agent, which takes
the Critic’s findings and minimally rewrites the original answer. It also
includes callbacks to clean up the LLM output and ensure it's in the right
format.
agents/sub_agents/reviser/prompt.py: Contains the Reviser prompt, which
instructs the agent to revise the answer text based on the verified claim
verdicts.
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:
Docker Model Runner: Run and serve
open-source models locally via OpenAI-compatible APIs.
Docker MCP Catalog and
Toolkit: Launch and manage
tool integrations that follow the Model Context Protocol (MCP) standard.
Docker MCP Gateway: Orchestrate and manage
MCP servers to connect agents to external tools and services.
Docker Compose: Define and run
multi-container agentic AI applications with a single file, using the same
workflow.
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:
Containerize and run an Angular application using Docker.
Set up a local development environment for Angular inside a container.
Run tests for your Angular application within a Docker container.
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:
Basic understanding of TypeScript and JavaScript.
Familiarity with Node.js and npm for managing dependencies and running scripts.
Familiarity with Angular fundamentals.
Understanding of core Docker concepts such as images, containers, and Dockerfiles. If you're new to Docker, start with the Docker basics guide.
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:
You have installed the latest version of Docker Desktop.
You have a git client. The examples in this section use a command-line based git client, but you can use any client.
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:
Containerize an Angular application using Docker.
Create and optimize a Dockerfile for production builds.
Use multi-stage builds to minimize image size.
Serve the application efficiently with a custom Nginx configuration.
Build secure and maintainable Docker images by following best practices.
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.
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.
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.
Sign in to the DHI registry:
$ docker login dhi.io
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;"]
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:
Reduces the attack surface
Aligns with Docker’s recommendations for container hardening
Helps comply with stricter security policies in production environments
{{< /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.
This helps:
Reduce image size
Speed up the build process
Prevent sensitive or unnecessary files (like 
.env, 
.git, or 
node_modules) from being added to the final image.
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:
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:
The updated setup includes a clean, production-ready Nginx configuration tailored specifically for Angular.
Efficient multi-stage Docker build, ensuring a small and secure final image.
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.
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:
Uses the Dockerfile in the current directory (.)
Packages the application and its dependencies into a Docker image
Tags the image as docker-angular-sample so you can reference it later
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:
Repository – The name assigned to the image.
Tag – A version label that helps identify different builds (e.g., latest).
Image ID – A unique identifier for the image.
Created – The timestamp indicating when the image was built.
Size – The total disk space used by the image.
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
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:
Configure separate containers for production and development
Enable automatic file syncing using Compose Watch in development
Debug and live-preview your changes in real-time without manual rebuilds
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
The 
angular-prod service builds and serves your static production app using Nginx.
The 
angular-dev service runs your Angular development server with live reload and hot module replacement.
watch triggers file sync with Compose Watch.
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:
Open the 
src/app/app.component.html file in your text editor.
Locate the following line:
<h1>Docker Angular Sample Application</h1>
Change it to:
<h1>Hello from Docker Compose Watch</h1>
Save the file.
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:
Run Jasmine unit tests using the Angular CLI inside a Docker container.
Use Docker Compose to isolate your test environment.
Ensure consistency between local and container-based testing.
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:
Start the 
angular-test service defined in your 
compose.yaml file.
Execute the 
npm run test script using the same environment as development.
Automatically removes the container after tests complete, using the 
docker compose run --rm command.
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
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:
Created a 
angular-test service in 
compose.yaml to isolate test execution.
Reused the development 
Dockerfile.dev to ensure consistency between dev and test environments.
Ran tests inside the container using 
docker compose run --rm angular-test.
Ensured reliable, repeatable testing across environments without depending on your local machine setup.
Related resources
Explore official references and best practices to sharpen your Docker testing workflow:
Dockerfile reference – Understand all Dockerfile instructions and syntax.
Best practices for writing Dockerfiles – Write efficient, maintainable, and secure Dockerfiles.
Compose file reference – Learn the full syntax and options available for configuring services in 
compose.yaml.
docker compose run CLI reference – Run one-off commands in a service container.

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:
A Docker Hub account with a generated access token.
An active Azure DevOps project with a connected Git repository.
A project that includes a valid 
Dockerfile at its root or appropriate build context.
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:
Configure Docker authentication securely.
Set up an automated pipeline to build and push images.
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:
Navigate to Project Settings > Service Connections in your Azure DevOps project.
Select New service connection > Docker Registry.
Choose Docker Hub and provide your Docker Hub credentials or access token.
Give the service connection a recognizable name, such as 
my-docker-registry.
Grant access only to the specific pipeline(s) that require it for improved security and least privilege.
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:
Triggers on commits and pull requests targeting the 
main branch.
Authenticates securely with Docker Hub using an Azure DevOps service connection.
Builds and tags the Docker image using Docker BuildKit for caching.
Pushes both buildId and latest tags to Docker Hub.
Logs out from Docker if running on a self-hosted Linux agent.
How the pipeline works
Step 1: Define pipeline triggers
trigger:
  - main
pr:
  - main
This pipeline is triggered automatically on:
Commits pushed to the 
main branch
Pull requests targeting 
main main branch
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:
imageName: your image path on Docker Hub
buildTag: a unique tag for each pipeline run
latestTag: a stable alias for your most recent image
The variable 
dockerUsername is not set automatically.
Set it securely in your Azure DevOps pipeline variables:
Go to Pipelines > Edit > Variables
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.
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.
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.
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.
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:
Two tags: one with the unique Build ID and one as latest
Docker BuildKit enabled for faster builds and efficient layer caching
Cache pull from the most recent pushed latest image
Software Bill of Materials (SBOM) for supply chain transparency
Provenance attestation to verify how and where the image was built
Learn more:
Docker task for Azure Pipelines
Docker SBOM Attestations
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:
$(buildTag) ensures traceability per run.
latest is used for most recent image references.
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')
Executes docker logout at the end of the pipeline on Linux-based self-hosted agents to proactively clean up credentials and enhance security posture.
Summary
With this Azure Pipelines CI setup, you get:
Secure Docker authentication using a built-in service connection.
Automated image building and tagging triggered by code changes.
Efficient builds leveraging Docker BuildKit cache.
Safe cleanup with logout on persistent agents.
Build images that meet modern software supply chain requirements with SBOM and attestation
Learn more
Azure Pipelines Documentation: Comprehensive guide to configuring and managing CI/CD pipelines in Azure DevOps.
Docker Task for Azure Pipelines: Detailed reference for using the Docker task in Azure Pipelines to build and push images.
Bake targets: Group builds, run checks, and export artifacts.
Docker Build Cloud: Learn about Docker's managed build service for faster, scalable, and multi-platform image builds in the cloud.

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?
Containerize and run a Bun application using Docker
Set up a local environment to develop a Bun application using containers
Prerequisites
Basic understanding of JavaScript is assumed.
You must have familiarity with Docker concepts like containers, images, and Dockerfiles. If you are new to Docker, you can start with the Docker basics guide.
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
You have a Git client. The examples in this section use a command-line based Git client, but you can use any client.
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.
Sign in to the DHI registry:
$ docker login dhi.io
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:
Sets the working directory in the container to 
/app.
Copies the content of the current directory to the 
/app directory in the container.
Exposes port 3000, where the API is listening for requests.
And finally, starts the server when the container launches with the command 
bun server.js.
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:
Configuring Compose to automatically update your running Compose services as you edit and save your code
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.
Related information:
Compose file reference
Compose file watch
Multi-stage builds

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:
Pull a coding model and start Claude Code with Docker Model Runner
Make the endpoint configuration persistent
Verify the local API endpoint and inspect requests
Package 
gpt-oss with a larger context window for longer prompts
Prerequisites
Before you start, make sure you have:
Docker Desktop or Docker Engine installed
Docker Model Runner enabled
Claude Code installed
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
Docker Model Runner overview
Docker Model Runner API reference
IDE and tool integrations

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:
Pull a coding model and start Docker Model Runner with TCP enabled
Allow the sandbox to reach Docker Model Runner on your host
Create a Claude Code sandbox and set the local endpoint persistently
Launch Claude Code with a local model and verify the connection
Package 
gpt-oss with a larger context window for longer prompts
How the pieces fit together
Three components cooperate at runtime:
Docker Model Runner runs on your host and serves an
Anthropic-compatible API at 
http://localhost:12434.
The Docker Sandbox runs Claude Code inside an isolated microVM. The
microVM has its own network and can't reach your host's 
localhost
directly.
The sandbox proxy sits on your host and brokers every outbound
request from the sandbox. It enforces network policy and translates the
special hostname 
host.docker.internal to 
localhost.
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:
Docker Desktop or Docker Engine installed
Docker Model Runner enabled
Docker Sandboxes (
sbx) version 0.39.0 or later installed and signed in
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
Use Claude Code with Docker Model Runner
Get started with Docker Sandboxes
Claude Code in Docker Sandboxes
Docker Model Runner overview
Docker Model Runner API reference

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.
Containerize and run a C++ application using a multi-stage Docker build
Build and run a C++ application using Docker Compose
Set up a local environment to develop a C++ application using containers
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
You have a Git client. The examples in this section use a command-line based Git client, but you can use any client.
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:
Build stage: This stage uses the 
ubuntu:latest image to compile the C++ code and create a static binary.
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
You have a Git client. The examples in this section use a command-line based Git client, but you can use any client.
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:
Dockerfile
.dockerignore
compose.yml
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:
Configuring Compose to automatically update your running Compose services as you edit and save your code
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.
Related information:
Compose file reference
Compose file watch
Multi-stage builds

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:
Run a local containerized database
Access the shell of a containerized database
Connect to a containerized database from your host
Connect to a containerized database from another container
Persist database data in a volume
Build a customized database image
Use Docker Compose to run a database
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
Most popular database systems, including MySQL, PostgreSQL, and MongoDB, have a
Docker Official Image available on Docker Hub. These images are a curated set
images that follow best practices, ensuring that you have access to the latest
features and security updates. To get started, visit
Docker Hub and search for the database you're
interested in. Each image's page provides detailed instructions on how to run
the container, customize your setup, and configure the database according to
your needs. For more information about the MySQL image used in this guide, see the Docker Hub MySQL image page.
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:
--name my-mysql assigns the name my-mysql to your container for easier
reference.
-e MYSQL_ROOT_PASSWORD=my-secret-pw sets the root password for MySQL to
my-secret-pw. Replace my-secret-pw with a secure password of your choice.
-e MYSQL_DATABASE=mydb optionally creates a database named mydb. You can
change mydb to your desired database name.
-d runs the container in detached mode, meaning it runs in the background.
mysql:latest specifies that you want to use the latest version of the MySQL
image.
To verify that your container is running, run 
docker ps in a terminal
{{< /tab >}}
{{< tab name="GUI" >}}
To run a container using the GUI:
In the Docker Desktop Dashboard, select the global search at the top of the window.
Specify 
mysql in the search box, and select the 
Images tab if not already
selected.
Hover over the 
mysql image and select 
Run.
The Run a new container modal appears.
Expand Optional settings.
In the optional settings, specify the following:
Container name: 
my-mysql
Environment variables:
MYSQL_ROOT_PASSWORD:
my-secret-pw
MYSQL_DATABASE:
mydb
Select 
Run.
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:
docker exec tells Docker you want to execute a command in a running
container.
-it ensures that the terminal you're accessing is interactive, so you can
type commands into it.
my-mysql is the name of your MySQL container. If you named your container
differently when you ran it, use that name instead.
bash is the command you want to run inside the container. It opens up a bash
shell that lets you interact with the container's file system and installed
applications.
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" >}}
Open the Docker Desktop Dashboard and select the Containers view.
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:
In a terminal, run 
docker rm --force my-mysql to remove the container
named 
my-mysql.
Or, in the Docker Desktop Dashboard, select the Delete icon next to your
container in the Containers view.
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:
In the Docker Desktop Dashboard, select the global search at the top of the window.
Specify 
mysql in the search box, and select the 
Images tab if not already
selected.
Hover over the 
mysql image and select 
Run.
The Run a new container modal appears.
Expand Optional settings.
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
Select 
Run.
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:
In a terminal, run 
docker rm --force my-mysql to remove the container
named 
my-mysql.
Or, in the Docker Desktop Dashboard, select the Delete icon next to your
container in the Containers view.
To create a network and run containers on it:
Run the following command to create a Docker network named my-network.
$ docker network create my-network
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
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.
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
Verify that the containers can communicate. For this example, you'll access
phpMyAdmin and verify that it connects to the database.
Open http://localhost:8080 to access your phpMyAdmin container.
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:
In a terminal, run 
docker rm --force my-mysql to remove the container
named 
my-mysql.
Or, in the Docker Desktop Dashboard, select the Delete icon next to your
container in the Containers view.
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:
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.
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.
Stop and remove the container. Without a volume, the table you created would
be lost when removing the container.
$ docker rm --force my-mysql
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
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:
Run a container with a volume attached.
In the Docker Desktop Dashboard, select the global search at the top of the window.
Specify 
mysql in the search box, and select the Images tab if not
already selected.
Hover over the mysql image and select Run.
The Run a new container modal appears.
Expand Optional settings.
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
Here, the name of the volume is 
my-db-volume and it is mounted in the
container at 
/var/lib/mysql.
Select 
Run.
Create some data in the database.
In the Containers view, next to your container select the Show
container actions icon, and then select Open in terminal.
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`.
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.
Run a container with a volume attached.
In the Docker Desktop Dashboard, select the global search at the top of the window.
Specify 
mysql in the search box, and select the Images tab if not
already selected.
Hover over the mysql image and select Run.
The Run a new container modal appears.
Expand Optional settings.
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
Select 
Run.
Verify that the table you created still exists.
In the Containers view, next to your container select the Show
container actions icon, and then select Open in terminal.
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:
In a terminal, run 
docker rm --force my-mysql to remove the container
named 
my-mysql.
Or, in the Docker Desktop Dashboard, select the Delete icon next to your
container in the Containers view.
To build and run your custom image:
Create a Dockerfile.
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.
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.
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
Build your image.
In a terminal, change directory to the directory where your 
Dockerfile
is located.
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.
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
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
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:
Create a Docker Compose file.
Create a file named 
compose.yaml in your project directory. This file
will define the services, networks, and volumes.
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.
Run Docker Compose.
Open a terminal and change directory to the directory where your
compose.yaml file is located.
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.
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.
Related information:
Docker Hub database images
Dockerfile reference
Compose file reference
CLI reference
Database samples

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?
Containerize and run a Deno application using Docker
Set up a local environment to develop a Deno application using containers
Use Docker Compose to run the application.
Prerequisites
Basic understanding of JavaScript is assumed.
You must have familiarity with Docker concepts like containers, images, and Dockerfiles. If you are new to Docker, you can start with the Docker basics guide.
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
You have a Git client. The examples in this section use a command-line based Git client, but you can use any client.
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.
Sign in to the DHI registry:
$ docker login dhi.io
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:
Sets the working directory in the container to 
/app.
Copies 
server.ts into the container.
Sets the user to 
deno to run the application as a non-root user.
Exposes port 8000 to allow traffic to the application.
Runs the Deno server using the 
CMD instruction.
Uses the 
--allow-net flag to allow network access to the application. The 
server.ts file uses the Oak framework to create a simple API that listens on port 8000.
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:
Configuring Compose to automatically update your running Compose services as you edit and save your code
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.
Related information:
Compose file reference
Compose file watch
Multi-stage builds

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:
Use Docker to launch up a Dex container.
Use mock OAuth in the GitHub Action (GHA) without relying on an external OAuth provider.
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:
issuer: The public URL of Dex.
storage: Using in-memory storage for simplicity.
web: Dex will listen on port 5556.
staticClients: Defines a client application (example-app) with its redirect URI and secret.
enablePasswordDB: Enables static password authentication.
staticPasswords: Defines a static user for authentication. The hash is a bcrypt hash of the password.
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
Docker Desktop or Docker Engine with BuildKit enabled
A Docker Hub account authenticated with 
docker login and 
docker login dhi.io
A Backstage project created with 
@backstage/create-app
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 .
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.
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.
When creating a customization, only add what your application needs at runtime:
System packages - add shared libraries (such as 
sqlite-libs) and
language runtimes from the DHI catalog (such as 
python-3.14).
Do not add build tools (such as 
g++, 
make, or 
python3 from Alpine).
Build tools - keep these in the 
-dev build stage only. Never add them
to the runtime customization.
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:
sqlite-libs - the shared library that the compiled 
better-sqlite3 native module links against (added as a system package).
Python - if your Backstage plugins or configuration require Python at runtime. Added as the 
python-3.14 system package from the DHI catalog. Unlike 
python3 installed via 
apk, this package is patched by Docker and tracked in the image SBOM.
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:
Open the mirrored Node.js repository in Docker Hub.
Select Customize and choose the 
node:24-alpine3.23 tag.
Under Packages, add 
sqlite-libs and 
python-3.14.
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.
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:
The runtime image is distroless — no shell, no package manager.
Docker automatically rebuilds your customized image when the base Node.js image or any of its packages receive a security patch.
The full chain of trust is maintained, including SLSA Build Level 3 provenance.
Both the Node.js and Python runtimes are tracked in the image SBOM.
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.
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)
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
Customize an image — complete reference on the Enterprise customization UI.
Create and build a DHI — learn how to write a DHI definition file, build images locally.
Use the DHI CLI — manage DHI images, mirrors, and customizations from the command line.
Migrate to DHI — for applications that work with standard DHI images without additional packages.
Compare images — evaluate security improvements between your original and hardened images.
Docker Debug — troubleshoot distroless containers that have no shell.

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.
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.
Link the secret to a service account
Link the pull secret to the 
default service account in your project so that
all deployments can pull DHI images automatically:
oc secrets link default dhi-pull-secret --for=pull
To use the secret with a specific service account instead:
oc secrets link <service-account-name> dhi-pull-secret --for=pull
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:
Use a DHI 
-dev variant as the build stage (it has a shell).
Build your application and set GID 0 ownership in the build stage.
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
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.
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:
Look up the current user’s home directory or username
Write to directories owned by a specific UID
Check 
/etc/passwd for the running user
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"]
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.
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
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.
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
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
Use an image in Kubernetes — general DHI Kubernetes deployment guide.
Customize an image — add packages to DHI images using Enterprise customization.
Debug a container — troubleshoot distroless containers with Docker Debug (local development).
Managing SCCs — Red Hat’s reference documentation on Security Context Constraints.
Creating images for OpenShift — Red Hat’s guidelines for building OpenShift-compatible container images.

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
Docker Desktop, authenticated
to 
dhi.io. Sign in with 
docker login dhi.io. Docker Desktop includes
Docker Scout, which fetches the VEX attestation.
A vulnerability scanner. This guide shows examples for Docker Scout,
Trivy, and Grype.
Trivy and Grype can also run as Docker containers with no installation needed.
jq (optional), for filtering the VEX file.
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.
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.
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:
vulnerable_code_cannot_be_controlled_by_adversary: the vulnerable code
path exists in the package, but an attacker cannot trigger it in this
configuration.
vulnerable_code_not_present: the vulnerable code was not compiled into
this build or is otherwise absent.
inline_mitigations_already_exist: Docker has applied a backport or patch
that addresses the CVE in this image.
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.
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
Scan with other tools: Learn how to apply DHI VEX statements with Trivy
(VEX Hub) and Grype in Scan Docker Hardened
Images.
Write your own VEX for child images: If you build on top of a DHI and
want to suppress CVEs in packages you add, see Create an exception using
VEX.
VEX status reference: For status definitions, justification codes, and
why DHI does not use 
fixed, see Vulnerability Exploitability eXchange
(VEX).
Browse the VEX feed directly: The raw VEX data is published at
github.com/docker-hardened-images/advisories,
organized by image name.

Containerize a Django application
You have installed the latest version of Docker
Desktop.
You have uv installed, or you can use Docker to
scaffold the project without a local Python or uv installation.
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:
Initialize a Django project using uv, either locally or inside a Docker
Hardened Image container.
Create a production-ready Dockerfile using Docker Hardened Images
(DHI).
Add a 
development stage to your Dockerfile and configure Compose Watch for
automatic code syncing.
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)" >}}
Initialize the project pinned to Python 3.14, then navigate into it:
$ uv init --python 3.14 django-docker
$ cd django-docker
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.
Create the project directory and navigate into it:
$ mkdir django-docker && cd django-docker
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 ."
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.
Sign in to the DHI registry:
$ docker login dhi.io
Create a 
.dockerignore file to exclude local artifacts from the build
context:
.venv/
__pycache__/
*.pyc
.git/
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"]
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.
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:
Bootstrapped a Django project using uv, with options for both local and
containerized setup.
Created a production-ready Dockerfile using Docker Hardened Images and uv for
dependency management.
Added a 
development stage to the 
Dockerfile and configured Compose Watch
for fast iterative development with a PostgreSQL database.
Related information:
Dockerfile reference
Compose file reference
Use Compose Watch
Docker Hardened Images
Multi-stage builds
uv documentation

.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:
Containerize and run a .NET application
Set up a local environment to develop a .NET application using containers
Run tests for a .NET application using containers
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
You have installed the latest version of Docker
Desktop.
You have a git client. The examples in this
section use a command-line based git client, but you can use any client.
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).
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.
Sign in to the DHI registry:
$ docker login dhi.io
Pull the .NET SDK DHI (check the catalog for available versions):
$ docker pull dhi.io/dotnet:10-sdk
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"]
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:
Dockerfile
.dockerignore
compose.yaml
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:
Adding a local database and persisting data
Configuring Compose to automatically update your running Compose services as you edit and save your code
Creating a development container that contains the .NET Core SDK tools and dependencies
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.
Stash any previous changes.
$ git stash -u
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
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.
Related information:
docker compose run

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:
Store your Firecrawl API key on the host as a sandbox secret
Add the Firecrawl mixin to a v3 workload
Check that the SDK, the proxy-managed credential, and the network rule work
Call Firecrawl from inside the sandbox
Understand the limits the sandbox network policy places on the kit
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:
Docker Sandboxes (
sbx) installed and signed in,
version 0.46 or later
A global network policy. If you haven't set one, run
sbx policy init balanced
A Firecrawl API key. Keys start
with 
fc-
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.
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
Use kits
Build a tool mixin
Credentials
Firecrawl kit source
Firecrawl documentation

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:
Enable Docker MCP Toolkit in Docker Desktop
Add the Docker Hub MCP server
Connect Claude Code to the MCP Gateway (GUI or CLI)
Verify MCP connectivity inside Claude
Ask Claude to generate and save a Compose file for a Node.js + PostgreSQL app
Deploy it instantly with 
docker compose up
Use Claude Code and Docker MCP Toolkit to generate a Docker Compose file from natural language
Setup: Enable MCP Toolkit → Add Docker Hub MCP server → Connect Claude Code
Use Claude: Describe your stack in plain English
Automate: Claude queries Docker Hub via MCP and builds a complete 
docker-compose.yaml
Deploy: Run 
docker compose up → Node.js + PostgreSQL live on 
localhost:3000
Benefit: Zero YAML authoring. Zero image searching. Describe once → Claude builds it.
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:
Docker Desktop installed
Enable Docker Desktop updated with MCP Toolkit support
Claude Code installed
3. Install the Docker Hub MCP server
Open Docker Desktop
Select MCP Toolkit
Go to the Catalog tab
Search for Docker Hub
Select the Docker HubMCP server
Add the MCP server, then open the Configuration tab
Enter your Docker Hub username
Create a read-only personal access token and enter your access token under Secrets
Save the configuration
Public images work without credentials. For private repositories, you can add your Docker Hub username and token later.
4. Connect Claude Code to Docker MCP Toolkit
You can connect from Docker Desktop or using the CLI.
Option A. Connect with Docker Desktop
Open MCP Toolkit
Go to the Clients tab
Locate Claude Code
Select Connect
Option B. Connect using the CLI
$ claude mcp add MCP_DOCKER -s user -- docker mcp gateway run
5. Verify MCP servers inside Claude Code
Navigate to your project folder:
$ cd /path/to/project
Start Claude Code:
$ claude
In the input box, type:
/mcp
You should now see:
The MCP gateway (for example 
MCP_DOCKER)
Tools provided by the Docker Hub MCP server
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:
Pull the Node and Postgres images selected through Docker Hub MCP
Create networks and volumes
Start the containers
Open your browser:
http://localhost:3000
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
Explore the 220+ MCP servers available in the Docker MCP catalog
Connect Claude Code to your databases, internal APIs, and team tools
Share your MCP setup with your team so everyone works consistently
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
Explore the MCP Catalog: Discover containerized, security-hardened MCP servers
Get started with MCP Toolkit in Docker Desktop: Requires version 4.48 or newer to launch automatically
Read the MCP Horror Stories series: Learn about common MCP security pitfalls and how to avoid them

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?
Create a Golang application with custom Prometheus metrics.
Containerize a Golang application.
Use Docker Compose to run multiple services and connect them together to monitor a Golang application with Prometheus and Grafana.
Visualize the metrics using Grafana dashboards.
Prerequisites
A good understanding of Golang is assumed.
You must me familiar with Prometheus and creating dashboards in Grafana.
You must have familiarity with Docker concepts like containers, images, and Dockerfiles. If you are new to Docker, you can start with the Docker basics guide.
Next steps
You will create a Golang server and expose metrics using Prometheus.
Building the application
Prerequisites
You have a Git client. The examples in this section use a command-line based Git client, but you can use any client.
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
main.go - The entry point of the application.
go.mod and go.sum - Go module files.
Dockerfile - Dockerfile used to build the app.
Docker/ - Contains the Docker Compose configuration files for Grafana and Prometheus.
compose.yaml - Compose file to launch everything (Golang app, Prometheus, and Grafana).
dashboard.json - Grafana dashboard configuration file.
Dockerfile - Dockerfile used to build the Golang app.
compose.yaml - Docker Compose file to launch everything (Golang app, Prometheus, and Grafana).
Other files are for licensing and documentation purposes.
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.
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:
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.
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.
Related information:
Dockerfile reference
.dockerignore file
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:
Golang application service: This service builds the Golang application using the Dockerfile and runs it in a container. It exposes the application's port 
8000 and connects to the 
go-network network. It also defines a health check to monitor the application's health. You have also used 
healthcheck to monitor the health of the application. The health check runs every 30 seconds and retries 5 times if the health check fails. The health check uses the 
curl command to check the 
/health endpoint of the application. Apart from the health check, you have also added a 
develop section to watch the changes in the application's source code and rebuild the application using the Docker Compose Watch feature.
Prometheus service: This service runs the Prometheus server in a container. It uses the official Prometheus image 
prom/prometheus:v2.55.0. It exposes the Prometheus server on port 
9090 and connects to the 
go-network network. You have also mounted the 
prometheus.yml file from the 
Docker directory which is present in the root directory of your project. The 
prometheus.yml file contains the Prometheus configuration to scrape the metrics from the Golang application. This is how you connect the Prometheus server to the Golang application.
global:
  scrape_interval: 10s
  evaluation_interval: 10s
scrape_configs:
  - job_name: myapp
    static_configs:
      - targets: ["api:8000"]
In the 
prometheus.yml file, you have defined a job named 
myapp to scrape the metrics from the Golang application. The 
targets field specifies the target to scrape the metrics from. In this case, the target is the Golang application running on port 
8000. The 
api is the service name of the Golang application in the Docker Compose file. The Prometheus server will scrape the metrics from the Golang application every 10 seconds.
Grafana service: This service runs the Grafana server in a container. It uses the official Grafana image 
grafana/grafana:11.3.0. It exposes the Grafana server on port 
3000 and connects to the 
go-network network. You have also mounted the 
grafana.yml file from the 
Docker directory which is present in the root directory of your project. The 
grafana.yml file contains the Grafana configuration to add the Prometheus data source. This is how you connect the Grafana server to the Prometheus server. In the environment variables, you have set the Grafana admin user and password, which will be used to log in to the Grafana dashboard.
apiVersion: 1
datasources:
- name: Prometheus (Main)
  type: prometheus
  url: http://prometheus:9090
  isDefault: true
In the 
grafana.yml file, you have defined a Prometheus data source named 
Prometheus (Main). The 
type field specifies the type of the data source, which is 
prometheus. The 
url field specifies the URL of the Prometheus server to fetch the metrics from. In this case, the URL is 
http://prometheus:9090. 
prometheus is the service name of the Prometheus server in the Docker Compose file. The 
isDefault field specifies whether the data source is the default data source in Grafana.
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.
Related information:
Docker Compose overview
Compose file reference
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.
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.
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:
Create a 
Dockerfile which contains the instructions for building a container image for a program written in Go.
Run the image as a container in your local Docker instance and manage the container's lifecycle.
Use multi-stage builds for building small images efficiently while keeping your Dockerfiles easy to read and maintain.
Use Docker Compose to orchestrate running of multiple related containers together in a development environment.
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:
Docker running locally. Follow the instructions to download and install Docker.
An IDE or a text editor to edit files. Visual Studio Code is a free and popular choice but you can use anything you feel comfortable with.
A Git client. This guide uses a command-line based 
git client, but you are free to use whatever works for you.
A command-line terminal application. The examples shown in this module are from the Linux shell, but they should work in PowerShell, Windows Command Prompt, or OS X Terminal with minimal, if any, modifications.
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 a string containing a heart symbol (
<3) to requests to 
/.
It responds with 
{"Status" : "OK"} JSON to a request to 
/health.
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.
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 ./
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:
Create a blank database.
Register a new user account with the database engine.
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
In the SQL shell, create the database that the example application is going to use:
CREATE DATABASE mydb;
Register a new SQL user account with the database engine. Use the username 
totoro.
CREATE USER totoro;
Give the new user the necessary permissions:
GRANT ALL ON DATABASE mydb TO totoro;
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:
You can update your local copy of 
docker-gs-ping to match the new extended version presented in this chapter; or
You can clone the docker/docker-gs-ping-dev repository. This latter approach is recommended.
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:
An HTTP 
POST request to 
/send containing a 
{ "value" : string } JSON must save the value to the database.
You also have an update for another business requirement. The requirement was:
The application responds with a text message containing a heart symbol ("
<3") on requests to 
/.
And now it's going to be:
The application responds with the string containing the count of messages stored in the database, enclosed in the parentheses.
Example output: 
Hello, Docker! (7)
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.
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.
You map container port 
8080 to host port 
80 this time. Thus, for 
GET requests you can get away with literally 
curl localhost:
$ curl localhost
Hello, Docker! (0)
Or, if you prefer, a proper URL would work just as well:
$ curl http://localhost/
Hello, Docker! (0)
The total number of stored messages is 
0 for now. This is fine, because you haven't posted anything to your application yet.
You refer to the database container by its hostname, which is 
db. This is why you had 
--hostname db when you started the database container.
The actual password doesn't matter, but it must be set to something to avoid confusing the example application.
The container you've just run is named 
rest-server. These names are useful for managing the container lifecycle:
# Don't do this just yet, it's only an example:
$ docker container rm --force rest-server
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:
Using a bind mount for the source code for local development but not when running the CI tests;
Switching between using a pre-built image for the frontend for some API application vs creating a bind mount for source code;
Adding additional services for integration testing;
And many more...
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:
PGUSER=${PGUSER:-totoro} means that inside the container, the environment variable 
PGUSER shall be set to the same value as it has on the host machine where Docker Compose is run. If there is no environment variable with this name on the host machine, the variable inside the container gets the default value of 
totoro.
PGPASSWORD=${PGPASSWORD:?database password not set} means that if the environment variable 
PGPASSWORD isn't set on the host, Docker Compose will display an error. This is OK, because you don't want to hard-code default values for the password. You set the password value in the 
.env file, which is local to your machine. It is always a good idea to add 
.env to 
.gitignore to prevent the secrets being checked into the version control.
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.
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:
Start a CockroachDB Cluster in Docker article; and
Documentation for Docker Compose keywords 
deploy and 
replicas.
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
Gemini CLI installed and authenticated.
Docker Desktop with the MCP Toolkit extension enabled.
An active Grafana instance.
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.
Navigate to Administration > Users and access > Service accounts in your Grafana dashboard.
Create a new Service Account (e.g., 
gemini-mcp-connector).
Assign the Viewer role (or Editor if you require alert management capabilities).
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.
Open the MCP Toolkit in Docker Desktop.
Locate Grafana in the Catalog and add it to your active servers.
In the Configuration view, define the following:
Grafana URL: The endpoint or URL of your instance.
Service Account Token: The token generated in the previous step.
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.
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.
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.
Dashboard Navigation
How many dashboards do we have?
Tell me the summary of X dashboard
Other scenarios
Imagine you get a page that an application is slow. You could:
Use 
list_alert_rules to see which alert is firing.
Use 
search_dashboards to find the relevant application dashboard.
Use 
get_panel_image on a key panel to see the performance spike visually.
Use 
query_loki_logs to search for "error" or "timeout" messages during the time of the spike.
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
Learn about Advanced LogQL queries
Set up Team-wide MCP configurations
Explore Grafana alerting with MCP
Get help in the Docker Community Forums
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:
Containerize and run a Spring Boot application with Maven
Set up a local development environment to connect a database to the container, configure a debugger, and use Compose Watch for live reload
Run your unit tests inside a container
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
You have installed the latest version of Docker Desktop.
Docker adds new features regularly and some parts of this guide may
work only with the latest version of Docker Desktop.
You have a Git client. The examples in this
section use a command-line based Git client, but you can use any client.
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.
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" ]
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.
Dockerfile
.dockerignore
compose.yaml
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:
Adding a local database and persisting data
Creating a development container to connect a debugger
Configuring Compose to automatically update your running Compose services as
you edit and save your code
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:
Uncomment all of the database instructions. You'll now use a database service
instead of local storage for the data.
Remove the top-level 
secrets element as well as the element inside the 
db
service. This example uses the environment variable for the password rather than secrets.
Remove the 
user element from the 
db service. This example specifies the
user in the environment variable.
Update the database environment variables. These are defined by the Postgres
image. For more details, see the
Postgres Official Docker Image.
Update the healthcheck test for the 
db service and specify the user. By
default, the healthcheck uses the root user instead of the 
petclinic user
you defined.
Add the database URL as an environment variable in the 
server service. This
overrides the default value defined in
spring-petclinic/src/main/resources/application-postgres.properties.
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:
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.
You should now see the connection in the logs of your Compose application.
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.
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:
Run a personal Jupyter Server with JupyterLab on your local machine
Customize your JupyterLab environment
Share your JupyterLab notebook and environment with other data scientists
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:
Containerization ensures a consistent JupyterLab environment across all
deployments, eliminating compatibility issues.
Containerized JupyterLab simplifies sharing and collaboration by removing the
need for manual environment setup.
Containers offer scalability for JupyterLab, supporting workload distribution
and efficient resource management with platforms like Kubernetes.
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:
-p 8889:8888: Maps port 8889 from the host to port 8888 on the container.
start-notebook.py --NotebookApp.token='my-token': Sets an access token
rather than using a random token.
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.
Open a web browser and access your JupyterLab container at localhost:8889/lab?token=my-token.
In the Launcher, under Notebook, select Python 3.
In the notebook, specify the following to install the necessary packages.
!pip install matplotlib scikit-learn
Select the play button to run the code.
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"
)
Select the play button to run the code. You should see a scatter plot of the
Iris dataset.
In the top menu, select File and then Save Notebook.
Specify a name in the 
work directory to save the notebook to the volume.
For example, 
work/mynotebook.ipynb.
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.
In the Launcher, under Notebook, select Python 3.
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"
)
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
Sign up or sign in to Docker Hub.
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
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
Verify that you pushed the image to Docker Hub.
Go to Docker Hub.
Select My Hub > Repositories.
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.
Sign in to Docker Desktop.
In the Docker Dashboard, select Volumes.
Select the jupyter-data volume by selecting the name.
Select the Exports tab.
Select Quick export.
For Location, select Registry.
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.
Select Save.
Verify that you exported the volume to Docker Hub.
Go to Docker Hub.
Select My Hub > Repositories.
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:
Sign in to Docker Desktop.
In the Docker Dashboard, select Volumes.
Select Create to create a new volume.
Specify a name for the new volume. For this example, use 
jupyter-data-2.
Select Create.
In the list of volumes, select the jupyter-data-2 volume by selecting the
name.
Select Import.
For Location, select Registry.
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.
Select Import.
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.
Related information:
Dockerfile reference
Compose file reference
Docker CLI reference
Jupyter Docker Stacks docs

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:
Use Docker to launch up a Kafka cluster
Connect a non-containerized app to the cluster
Connect a containerized app to the cluster
Deploy Kafka-UI to help with troubleshooting and debugging
Prerequisites
The following prerequisites are required to follow along with this how-to guide:
Docker Desktop
Node.js and yarn
Basic knowledge of Kafka and Docker
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.
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.
Start a Kafka container by running the following command:
$ docker run -d --name=kafka -p 9092:9092 apache/kafka
Once the image pulls, you’ll have a Kafka instance up and running within a second or two.
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
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.
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.
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
Clone the GitHub repository locally.
$ git clone https://github.com/dockersamples/kafka-development-node.git
Navigate into the project.
cd kafka-development-node/app
Install the dependencies using yarn.
$ yarn install
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
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:
Host connections (those coming through the host’s mapped port) - these will need to connect using localhost
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.
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.
If you have the Node app running from the previous step, go ahead and stop it by pressing 
ctrl+c in the terminal.
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
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.
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:
Understand the Chat Completions API and how to structure messages for a model
Use prompt engineering techniques including system prompts, few-shot examples, and structured output
Implement tool calling and the agentic loop in code
Build a RAG pipeline that grounds model responses in your own data
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:
Generate and inspect an SPDX SBOM attached to a container image with 
--sbom=true
Generate SLSA build provenance with 
--provenance=mode=max and understand how multi-stage builds are fully recorded
Install Cosign and use key-based signing to sign and verify a container image
Write an OpenVEX statement to declare CVE exploitability status and attach it as a signed attestation
Understand how SBOMs, provenance, signatures, and VEX complement each other in a complete supply chain story
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:
Read an image's layer history and understand the layer cleanup pitfall
Restructure a Dockerfile for fast, cache-efficient incremental builds
Write a 
.dockerignore file and run containers as a non-root user
Use multi-stage builds to run tests as a build gate and dramatically reduce image size
Choose the right base image for production, including Docker Hardened Images
Inject secrets safely at build time using 
--mount=type=secret
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:
Define a multi-service application in a 
compose.yaml file and manage it with Compose commands
Control service startup order using health checks and 
depends_on conditions
Iterate on code without manual rebuilds using Compose watch mode
Persist data across container restarts with named volumes
Modularize a stack across multiple files using the 
include directive
Use 
config, 
logs, and 
exec to inspect and debug a running stack
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:
Understand what containers are and how they differ from virtual machines
Run containers in the background, inspect their logs and filesystem, and manage their lifecycle
Write a 
Dockerfile to package an application, using layer caching for fast rebuilds
Build a custom image with 
docker build and run it as a container
Optionally publish your image to Docker Hub
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:
Run a PostgreSQL database in a container with no local installation
Use bind mounts to seed a database with schema and initial data at startup
Write a 
compose.yaml that codifies the entire dev stack for the team
Add a pgAdmin container to visualize and inspect the database
Understand how containerized dev stacks reduce onboarding time and environment drift
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:
Set up a containerized local development environment with Docker Compose and Compose Watch
Write integration tests that spin up a real database using Testcontainers
Build a CI/CD pipeline that tests, builds, and pushes a container image automatically
Write Kubernetes manifests and deploy a live application to a k3s cluster
Configure the pipeline to cause an automatic deployment on every push to 
main
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:
Run LLMs locally via Docker Model Runner's OpenAI-compatible API
Connect a Node.js app to Docker Model Runner using the OpenAI SDK and the Compose 
models: integration
Perform sentiment analysis using low-temperature LLM classification
Use embeddings and cosine similarity to cluster semantically related feedback
Extract structured data from an LLM using 
response_format: { type: 'json_object' }
Generate context-aware responses to reviews informed by extracted product features
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:
Analyze a Node.js container image with Docker Scout to identify CVE and policy failures
Rewrite a Dockerfile to use a multi-stage build with DHI dev and runtime variants
Compare image size and vulnerability counts before and after the migration
Inspect supply chain attestations included with Docker Hardened Images (SBOMs, SLSA, VEX)
Export VEX documents for integration with external scanners such as Grype or Trivy
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
Create simple agents with Docker Agent
Use built-in generic agentic tools for common tasks
Integrate MCP servers from the MCP Toolkit
Share agents using the Docker Registry
Build multi-agent systems for complex workflows
Use Docker Model Runner with Docker Agent (preview)
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
Learn about the Docker MCP Gateway and its architecture
Run the MCP Gateway with a simple MCP server
Inject secrets securely into MCP servers
Filter tools to reduce noise and save tokens
Connect the MCP Gateway to your application using popular agentic frameworks
Configure and use custom MCP servers
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
Laravel is a popular PHP framework that allows developers to build web applications quickly and effectively. Docker Compose simplifies the management of development and production environments by defining essential services, like PHP, a web server, and a database, in a single YAML file. This guide provides a streamlined approach to setting up a robust Laravel environment using Docker Compose, focusing on simplicity and efficiency.
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:
Laravel Sail: An official package for easily starting Laravel in Docker.
Laradock: A community project that helps run Laravel applications in Docker.
What you’ll learn
How to use Docker Compose to set up a Laravel development and production environment.
Defining services that make Laravel development easier, including PHP-FPM, Nginx, and database containers.
Best practices for managing Laravel environments using containerization.
Who’s this for?
Developers who work with Laravel and want to streamline environment management.
DevOps engineers seeking efficient ways to manage and deploy Laravel applications.
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.
Docker: Make sure Docker is installed and running on your machine. Refer to the Docker installation guide to install Docker.
Docker Compose: Docker Compose is included with Docker Desktop, but you can also follow the Docker Compose installation guide if needed.
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 CLI: You should be comfortable using Laravel’s command-line tool (
artisan).
Laravel Project Structure: Familiarize yourself with Laravel’s folder structure (
app, 
config, 
routes, 
tests, etc.).
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.
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:
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.
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"]
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:
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.
It’s recommended to use 
alpine-based images in production for smaller image sizes, enhancing deployment speed and security.
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:
Use Docker to launch up a LocalStack container
Connect to LocalStack from a non-containerized application
Connect to LocalStack from a containerized application
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:
Docker Desktop
Node.js
Python and pip
Basic knowledge of Docker
Launching LocalStack
Launch a quick demo of LocalStack by using the following steps:
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
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.
Verify that LocalStack is up and running by selecting the container and checking the logs.
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.
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:
React: A user-friendly frontend for accessing the todo-list application
Node: A backend responsible for handling the HTTP requests.
MongoDB: A database to store all the to-do list data
LocalStack: Emulates the Amazon S3 service and stores and retrieve images.
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.
Change to the backend/ directory
$ cd backend/
Install the required dependencies:
$ npm install
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.
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',  
  },
});
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:
Navigate to the 
frontend directory:
$ cd frontend
Install the required dependencies
$ npm install
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.
You can verify the image is uploaded to the S3 bucket by checking the LocalStack container logs:
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.
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:
Modify the 
.env file under the 
backend/ directory to have the resources connect using the internal network names.
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
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.
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.
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.
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.
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:
Store your Mistral API key on the host as a sandbox secret
Build and publish a v3 workload kit with Vibe installed
Launch Vibe and verify that it receives a credential placeholder
Run a sample coding task and inspect the result
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:
Docker Desktop or Docker Engine with Buildx
Docker Sandboxes (
sbx) installed and signed in, version 0.45.0 or later for v3 kit support
A Mistral API key with an active completion allowance
Git, for cloning the sample project
A Docker Hub namespace, or another registry, if you want to publish your kit
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:
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:.
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.
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
Get started with Docker Sandboxes
Build an agent workload
Customize sandboxes with kits
Credentials and built-in services
Mistral Vibe

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:
Containerize and run a Next.js application using Docker.
Set up a local development environment for Next.js inside a container.
Run tests for your Next.js application within a Docker container.
To begin, you'll start by containerizing an existing Next.js application.
Prerequisites
Before you begin, make sure you're familiar with the following:
Basic understanding of JavaScript or TypeScript.
Basic knowledge of Node.js and npm for managing dependencies and running scripts.
Familiarity with React and Next.js fundamentals.
Understanding of Docker concepts such as images, containers, and Dockerfiles. If you're new to Docker, start with the Docker basics guide.
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:
You have installed the latest version of Docker Desktop.
You have a git client. The examples in this section use a command-line based git client, but you can use any client.
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:
Containerize a Next.js application using Docker.
Create and optimize a Dockerfile for production builds.
Use multi-stage builds to minimize image size.
Leverage Next.js standalone or export output for efficient containerization.
Follow best practices for building secure and maintainable Docker images.
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).
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.
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.
Sign in to the DHI registry:
$ docker login dhi.io
Pull the Node.js DHI (check the catalog for available versions):
$ docker pull dhi.io/node:24-alpine3.22-dev
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"]
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.
Sign in to the DHI registry:
$ docker login dhi.io
Pull the Node.js DHI (check the catalog for available versions):
$ docker pull dhi.io/node:24-alpine3.22-dev
Pull the Nginx DHI (check the catalog for available versions):
$ docker pull dhi.io/nginx:1.28.0-alpine3.21-dev
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;"]
This guide uses nginx-unprivileged instead of the standard Nginx image to run as a non-root user, following security best practices.
{{< /tab >}}
{{< /tabs >}}
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;
        }
    }
}
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
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.
This helps:
Reduce image size
Speed up the build process
Prevent sensitive or unnecessary files (like 
.env, 
.git, or 
node_modules) from being added to the final image.
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:
Multi-stage builds for optimized image size
Standalone: Node.js server on port 3000; Export: Nginx serving static files on port 8080
Non-root user for enhanced security
Proper file permissions and ownership
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.
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:
Uses the Dockerfile in the current directory (.)
Packages the application and its dependencies into a Docker image
Tags the image as nextjs-sample so you can reference it later
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:
Repository – The name assigned to the image.
Tag – A version label that helps identify different builds (e.g., latest).
Image ID – A unique identifier for the image.
Created – The timestamp indicating when the image was built.
Size – The total disk space used by the image.
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
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:
Configure separate containers for production and development
Enable automatic file syncing using Compose Watch in development
Debug and live-preview your changes in real-time without manual rebuilds
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
The 
nextjs-prod-standalone service builds and runs your production Next.js app using the standalone output.
The 
nextjs-dev service runs your Next.js development server with hot module replacement.
watch triggers file sync with Compose Watch.
WATCHPACK_POLLING=true ensures file changes are detected properly inside Docker.
The 
rebuild action for 
package.json ensures dependencies are reinstalled when the file changes.
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.
The Next.js development server automatically:
Enables Hot Module Replacement (HMR) for instant updates
Watches for file changes and recompiles automatically
Provides detailed error messages in the browser
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:
Open the 
app/page.tsx file in your text editor (or 
src/app/page.tsx if your project uses a 
src directory).
Locate the main content area and find a text element to modify.
Make a visible change, for example, update a heading:
<h1>Hello from Docker Compose Watch!</h1>
Save the file.
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:
Run unit tests using Vitest (or Jest) inside a Docker container.
Run lint (e.g. ESLint) inside a Docker container.
Use Docker Compose to run tests and lint in an isolated, reproducible environment.
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";
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:
Start the 
nextjs-test service (because of 
--profile tools).
Run your test script (
test:run or 
test -- --run) in the same environment as development.
Remove the container after the tests complete (
docker compose run --rm).
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:
Start the 
nextjs-lint service (because of 
--profile tools).
Run your lint script (
npm run lint, 
yarn lint, or 
pnpm lint depending on your lockfile) in the same environment as development.
Remove the container after lint completes.
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:
Installed and configured Vitest and React Testing Library for testing Next.js components.
Created 
nextjs-test and 
nextjs-lint services in 
compose.yaml (with 
tools profile) to isolate test and lint execution.
Reused the development 
Dockerfile.dev to ensure consistency between dev, test, and lint environments.
Ran tests inside the container using 
docker compose --profile tools run --rm nextjs-test.
Ran lint inside the container using 
docker compose --profile tools run --rm nextjs-lint.
Ensured reliable, repeatable testing and linting across environments without relying on local machine setup.
Related resources
Explore official references and best practices to sharpen your Docker testing workflow:
Dockerfile reference – Understand all Dockerfile instructions and syntax.
Best practices for writing Dockerfiles – Write efficient, maintainable, and secure Dockerfiles.
Compose file reference – Learn the full syntax and options available for configuring services in 
compose.yaml.
docker compose run CLI reference – Run one-off commands in a service container.
Next.js Testing Documentation – Official Next.js testing guide.

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:
Containerize and run a Node.js application using Docker.
Set up a local development environment using containers.
Run tests inside a Docker container.
Start by containerizing a Node.js application.
Prerequisites
Basic understanding of JavaScript and TypeScript.
Basic knowledge of Node.js and npm.
Familiarity with Docker concepts such as images, containers, and Dockerfiles. If you're new to Docker, start with the Docker basics guide.
Containerize a Node.js application
Prerequisites
You have installed the latest version of Docker Desktop.
You're familiar with basic Docker concepts. If you're new to Docker, start
with Build and share a containerized application.
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.
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:
Dockerfile
.dockerignore
compose.yaml
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.
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 >}}
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.
Open Chrome and go to 
chrome://inspect.
Select Configure and add 
localhost:9229.
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.
Related information:
Dockerfile reference
Compose file reference
docker compose run CLI reference

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:
Pull coding models for OpenCode
Configure OpenCode to use Docker Model Runner
Verify the local API endpoint and start OpenCode
Package 
gpt-oss with a larger context window when you need it
Prerequisites
Before you start, make sure you have:
Docker Desktop or Docker Engine installed
Docker Model Runner enabled
OpenCode installed
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:
~/.config/opencode/opencode.json for a global setup
opencode.json in your project root for a project-specific setup
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.
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
Docker Model Runner overview
Docker Model Runner API reference
IDE and tool integrations

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:
How to set up OpenTelemetry in a Node.js app.
How to run an OpenTelemetry Collector in Docker.
How to visualize traces with Jaeger.
How to use Docker Compose to manage the full observability stack.
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:
Add pgAdmin to your application stack
Configure pgAdmin to automatically connect to the development database
Adding pgAdmin to your stack
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'
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
Open pgAdmin by going to http://localhost:5050.
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
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.
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.
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.
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"
          }
        }
      }
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.
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
Update the application stack by running 
docker compose up again:
$ docker compose up
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:
Containerize and run a PHP application
Set up a local environment to develop a PHP application using containers
Run tests for a PHP application within containers
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
You have installed the latest version of Docker
Desktop.
You have a git client. The examples in this
section use a command-line based git client, but you can use any client.
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.
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:
Dockerfile
.dockerignore
compose.yaml
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:
Adding a local database and persisting data
Adding phpMyAdmin to interact with the database
Configuring Compose to automatically update your running Compose services as
you edit and save your code
Creating a development container that contains the dev dependencies
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 connect to the database
Update the 
compose.yaml file to add a database service and volume to persist data
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:
Uncomment and update the database instructions for MariaDB.
Add a secret to the server service to pass in the database password.
Add the database connection environment variables to the server service.
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
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:
Split the 
deps staged into two stages. One stage for production
(
prod-deps) and one stage (
dev-deps) to install development dependencies.
Create a common 
base stage.
Create a new 
development stage for development.
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.
Related information:
docker compose run

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:
Starting PostgreSQL with a single command
Understanding why containers lose data by default
Configuring volumes for persistent storage
Translating your setup to Docker Compose
Quick start (minimal viable container)
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.
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:
Automated schema creation with initialization scripts
Performance tuning for containerized workloads
Timezone and locale configuration
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:
Automate database, schema, and user creation during container startup
Tune PostgreSQL performance parameters for containerized workloads
Configure timezone and locale settings
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
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.
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)
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:
Container-to-container: Connect from your application container to PostgreSQL over a private Docker network. No ports need to be exposed to the host.
Host-to-container: Connect from your laptop or development machine using 
localhost and a published port.
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.
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:
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:
PostgreSQL URI format:
This is the standard PostgreSQL connection URI format that combines all connection parameters into a single string, widely supported by PostgreSQL clients and libraries.
postgresql://postgres:mysecretpassword@postgres-dev:5432/postgres
This command demonstrates passing a PostgreSQL URI connection string as an environment variable to a container, which your application can then read to connect to the database.
Example usage in a Docker run command:
docker run --rm -it \
  --network my-app-net \
  -e DATABASE_URL="postgresql://postgres:mysecretpassword@postgres-dev:5432/postgres" \
  alpine:latest \
  sh -c 'echo "DATABASE_URL is set to: $DATABASE_URL"'
PostgreSQL connection parameters:
This format uses key-value pairs separated by spaces, which many PostgreSQL client libraries accept as an alternative to URI format.
host=postgres-dev
port=5432
user=postgres
password=mysecretpassword
dbname=postgres
Example usage in application code (Python with psycopg2):
conn = psycopg2.connect(
    host="postgres-dev",
    port=5432,
    user="postgres",
    password="mysecretpassword",
    dbname="postgres"
)
Connecting to a specific database:
Replace the database name in the connection string to connect to a specific database instead of the default 
postgres database.
If you created a custom database (e.g., 
testdb), use:
postgresql://postgres:mysecretpassword@postgres-dev:5432/testdb
Example with SSL disabled (common in Docker networks):
Add 
?sslmode=disable to the connection string when connecting within a private Docker network where SSL encryption isn't required.
postgresql://postgres:mysecretpassword@postgres-dev:5432/testdb?sslmode=disable
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:
Host: 
localhost or 
127.0.0.1
Port: 
5432
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
Popular PostgreSQL GUI tools can connect using these common connection details: Host: 
localhost, Port: 
5432, User: 
postgres, Database: 
postgres (or your database name).
pgAdmin: A web-based PostgreSQL administration and development platform
DBeaver: A universal database tool that supports PostgreSQL and many other databases. Select PostgreSQL as the connection type
TablePlus: A modern, native database management tool for macOS and Windows with a clean interface
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
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:
Avoid using the 
postgres superuser: The default 
postgres user has full database privileges. Create dedicated users with only the permissions your application needs.
Use strong passwords: PostgreSQL passwords should be complex. Consider using environment variables or secrets management instead of 
hardcoding passwords.
Limit network exposure: Binding to 
127.0.0.1 (localhost only) is safer than exposing to all interfaces (
0.0.0.0).
Consider SSL/TLS: For production, configure PostgreSQL to require SSL connections. The Advanced Configuration and Initialization guide shows how to configure PostgreSQL settings.
Create application-specific users: Use initialization scripts to create users with limited privileges. For example, a read-only user for reporting or a user that can only access specific databases.
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:
The 
app service connects to PostgreSQL using the service name (
db) as the hostname in the connection string
PostgreSQL is accessible from your host at 
localhost:5432 for external tools
Both services are isolated on a custom network, providing network-level security
The 
depends_on directive ensures PostgreSQL starts before your application
PostgreSQL connection details for the app service:
Hostname: 
db (resolved by Docker DNS)
Port: 
5432 (PostgreSQL default port)
Database: 
mydb (as specified in the connection string)
User: 
postgres (or a custom user you've created)
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"
Both containers must be on the same Docker network (
my-app-net).
Verify the network exists: 
docker network ls
Check which network a container is on: 
docker inspect postgres-dev | grep NetworkMode
Ensure you're using a user-defined network, not the default bridge network
"Connection refused" or "could not connect to server"
PostgreSQL may still be initializing: PostgreSQL takes a few seconds to start and initialize the database cluster. Wait 5-10 seconds after container start and retry.
Check if the PostgreSQL container is running:
docker ps --filter name=postgres-dev
Check PostgreSQL logs for initialization or connection errors:
docker logs postgres-dev
Look for messages like "database system is ready to accept connections" to confirm PostgreSQL is fully started.
Verify the port mapping is correct:
docker port postgres-dev
This should show 
5432/tcp -> 127.0.0.1:5432 (or 
0.0.0.0:5432 if bound to all interfaces).
Test PostgreSQL connectivity from inside the container:
docker exec -it postgres-dev psql -U postgres -c "SELECT version();"
If this works but external connections fail, the issue is with port publishing, not PostgreSQL itself.
"Password authentication failed" or "FATAL: password authentication failed for user"
Confirm the password: Verify you're using the same password set in 
POSTGRES_PASSWORD when you started the container.
Existing volume with old credentials: If you reused an existing volume, the password from the original initialization is still in effect. The 
POSTGRES_PASSWORD environment variable only sets the password during the first database initialization. To reset:
Remove the volume: 
docker volume rm postgres_data
Or connect with the old password
Or change the password after connecting: 
ALTER USER postgres WITH PASSWORD 'newpassword';
Try connecting with password prompt: 
psql -h localhost -U postgres -W (the 
-W flag forces a password prompt)
Use PGPASSWORD environment variable: 
PGPASSWORD=mysecretpassword psql -h localhost -U postgres
Check PostgreSQL authentication configuration: If you've customized 
pg_hba.conf, verify the authentication method allows password authentication
"Network not found"
Ensure the network exists before starting containers: 
docker network create my-app-net
If using Docker Compose, the network is created automatically when you run 
docker compose up
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:
Image: Use the official 
dpage/pgadmin4 image
Networking: In a Docker Compose environment, pgAdmin connects to the database using the internal service name (for example, 
db:5432) rather than 
localhost
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.
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:
"too many connections" errors
High memory consumption due to connection overhead
Many short-lived connections (web applications, serverless functions)
Need to serve thousands of clients with limited database connections
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:
PgBouncer listens on port 6432, avoiding confusion with the direct PostgreSQL connection on port 5432
The 
depends_on directive with 
service_healthy condition ensures PgBouncer starts only after PostgreSQL is ready
pool_mode = transaction is the optimal choice for most web applications
The Percona PgBouncer image requires mounted configuration files (without the 
:ro flag, as the entrypoint script needs to modify them)
This example uses 
trust authentication for simplicity. In production, configure proper SCRAM-SHA-256 authentication
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:
-c: Number of simulated clients
-j: Number of threads
-T: Duration in seconds
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:
CPU and memory available
Docker networking overhead
Disk I/O speed
Whether connections are being rapidly opened and closed

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:
Use Docker to launch up a Postgres container
Pre-seed Postgres using a SQL script
Pre-seed Postgres by copying SQL files into Docker image
Pre-seed Postgres using JavaScript code
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:
Docker Desktop
Launching Postgres
Launch a quick demo of Postgres by using the following steps:
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
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
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.
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.
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
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
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;
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.
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.
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
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.
Create a .env file with the following:
POSTGRES_USER=postgres
POSTGRES_DB_HOST=localhost
POSTGRES_DB=sampledb
POSTGRES_PASSWORD=mysecretpassword
POSTGRES_PORT=5432
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();
Kick off the seeding process
$ node seed.js
You should see the following command:
Database seeded successfully!
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:
Containerize and run a Python application
Set up a local environment to develop a Python application using containers
Lint, format, typing and best practices
Start by containerizing an existing Python application.
Containerize a Python application
Prerequisites
You have installed the latest version of Docker Desktop.
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
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.
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:
Dockerfile
.dockerignore
compose.yaml
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.
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 >}}
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:
Configure and use Ruff for linting and formatting
Set up Pyright for static type checking
Automate checks with pre-commit hooks
These tools help maintain code quality and catch errors early in development.
Related information:
Ruff documentation
Pyright documentation
pre-commit framework
Next steps
Customize linting rules to match your team's style preferences
Explore advanced type checking features

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:
Containerize and run a R application
Set up a local environment to develop a R application using containers
Start by containerizing an existing R application.
Containerize a R application
Prerequisites
You have a git client. The examples in this section use a command-line based git client, but you can use any client.
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:
Dockerfile
.dockerignore
compose.yaml
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:
Adding a local database and persisting data
Configuring Compose to automatically update your running Compose services as you edit and save your code
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
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.
Related information:
Compose file reference
Compose file watch
Multi-stage builds

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:
Containerize and run a React.js application using Docker.
Set up a local development environment for React.js inside a container.
Run tests for your React.js application within a Docker container.
To begin, you’ll start by containerizing an existing React.js application.
Prerequisites
Before you begin, make sure you're familiar with the following:
Basic understanding of JavaScript or TypeScript.
Basic knowledge of Node.js and npm for managing dependencies and running scripts.
Familiarity with React.js fundamentals.
Understanding of Docker concepts such as images, containers, and Dockerfiles. If you're new to Docker, start with the Docker basics guide.
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:
You have installed the latest version of Docker Desktop.
You have a git client. The examples in this section use a command-line based git client, but you can use any client.
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:
Containerize a React.js application using Docker.
Create and optimize a Dockerfile for production builds.
Use multi-stage builds to minimize image size.
Serve the application efficiently with a custom NGINX configuration.
Follow best practices for building secure and maintainable Docker images.
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.
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.
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.
Sign in to the DHI registry:
$ docker login dhi.io
Pull the Node.js DHI (check the catalog for available versions):
$ docker pull dhi.io/node:24-alpine3.22-dev
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;"]
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:
Reduces the attack surface
Aligns with Docker’s recommendations for container hardening
Helps comply with stricter security policies in production environments
{{< /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.
This helps:
Reduce image size
Speed up the build process
Prevent sensitive or unnecessary files (like 
.env, 
.git, or 
node_modules) from being added to the final image.
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:
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:
Optimized browser caching and gzip compression
Secure, non-root logging to avoid permission issues
Support for React client-side routing by redirecting unmatched routes to 
index.html
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.
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:
Uses the Dockerfile in the current directory (.)
Packages the application and its dependencies into a Docker image
Tags the image as docker-reactjs-sample so you can reference it later
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:
Repository – The name assigned to the image.
Tag – A version label that helps identify different builds (e.g., latest).
Image ID – A unique identifier for the image.
Created – The timestamp indicating when the image was built.
Size – The total disk space used by the image.
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
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:
Configure separate containers for production and development
Enable automatic file syncing using Compose Watch in development
Debug and live-preview your changes in real-time without manual rebuilds
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
The 
react-prod service builds and serves your static production app using Nginx.
The 
react-dev service runs your React development server with live reload and hot module replacement.
watch triggers file sync with Compose Watch.
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,
  },
});
The 
server options in 
vite.config.ts are essential for running Vite inside Docker:
host: true allows the dev server to be accessible from outside the container.
port: 5173 sets a consistent development port (must match the one exposed in Docker).
strictPort: true ensures Vite fails clearly if the port is unavailable, rather than switching silently.
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:
Open the 
src/App.tsx file in your text editor.
Locate the following line:
<h1>Vite + React</h1>
Change it to:
<h1>Hello from Docker Compose Watch</h1>
Save the file.
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:
Run unit tests using Vitest inside a Docker container.
Use Docker Compose to run tests in an isolated, reproducible environment.
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,
  },
});
The 
test options in 
vitest.config.ts are essential for reliable testing inside Docker:
environment: "jsdom" simulates a browser-like environment for rendering and DOM interactions.
setupFiles: "./src/setupTests.ts" loads global configuration or mocks before each test file (optional but recommended).
globals: true enables global test functions like 
describe, 
it, and 
expect without importing them.
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:
Start the 
react-test service defined in your 
compose.yaml file.
Execute the 
npm run test script using the same environment as development.
Automatically remove the container after the tests complete 
docker compose run --rm command.
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:
Installed and configured Vitest and React Testing Library for testing React components.
Created a 
react-test service in 
compose.yaml to isolate test execution.
Reused the development 
Dockerfile.dev to ensure consistency between dev and test environments.
Ran tests inside the container using 
docker compose run --rm react-test.
Ensured reliable, repeatable testing across environments without relying on local machine setup.
Related resources
Explore official references and best practices to sharpen your Docker testing workflow:
Dockerfile reference – Understand all Dockerfile instructions and syntax.
Best practices for writing Dockerfiles – Write efficient, maintainable, and secure Dockerfiles.
Compose file reference – Learn the full syntax and options available for configuring services in 
compose.yaml.
docker compose run CLI reference – Run one-off commands in a service container.

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:
Use official ROS 2 base images from Docker Hub
Run ROS 2 in an Ubuntu container
Install ROS 2 packages and dependencies
Set up a development container for local development
Run a complete end-to-end example with Turtlesim
Prerequisites
Before you begin, make sure you're familiar with the following:
Docker Desktop: You must have Docker Desktop installed and running.
Docker concepts: You must understand core Docker concepts, such as images and containers.
ROS 2 concepts: Basic understanding of concepts like nodes, packages, topics, and services.
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:
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.
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.
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:
Update the package manager:
$ sudo apt update
Install the desired package:
$ sudo apt install $PACKAGE_NAME
Replace 
$PACKAGE_NAME with any package you want to install.
Some commonly used packages include:
ros-humble-turtlesim - Visualization and simulation tool
ros-humble-rviz2 - 3D visualization tool
ros-humble-rqt - Qt-based ROS graphical tools
ros-humble-demo-nodes-cpp - C++ demo nodes
ros-humble-demo-nodes-py - Python demo nodes
ros-humble-colcon-common-extensions - Build system extensions
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.
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.
Verify the workspace structure:
ws_linux/
├── compose.yml
├── Dockerfile
└── src/
    ├── package1/
    └── package2/
ws_mac/
├── compose.yml
├── Dockerfile
└── src/
    ├── package1/
    └── package2/
Explore the workspace layout
compose.yml : Defines how Docker Compose builds and runs the ROS 2 container, including mounts, environment variables, and networking settings.
Dockerfile : Builds the ROS 2 development image. It uses an official ROS 2 base image, creates a non-root development user, and installs required system and ROS 2 dependencies.
src : Contains all ROS 2 packages. This directory is mounted into the container as the active workspace.
Open and build the container
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.
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.
Once the container is running, execute commands inside it using 
exec:
$ docker compose exec ros2 /bin/bash
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:
Install XQuartz using Homebrew:
$ brew install --cask xquartz
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.
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:
Update the package manager:
$ sudo apt update
Install the Turtlesim package:
$ sudo apt install -y ros-humble-turtlesim
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
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.
Move the turtle around the window. You should see it draw a path as it moves.
Monitor topics
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
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
Open another terminal and connect to the same container, then update the package manager:
$ sudo apt update
Install rqt:
$ sudo apt install -y 'ros-humble-rqt*'
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.
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).
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
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()
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.
Related resources
ROS 2 Turtlesim tutorials
ROS 2 Concepts
Geometry Messages

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:
Containerize and run a Ruby on Rails application
Set up a local environment to develop a Ruby on Rails application using containers
Start by containerizing an existing Ruby on Rails application.
Containerize a Ruby on Rails application
Prerequisites
You have installed the latest version of Docker Desktop.
You have a Git client. The examples in this section show the Git CLI, but you can use any client.
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
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:
.dockerignore
compose.yaml
Dockerfile
bin/docker-entrypoint
To learn more about the files, see the following:
Dockerfile
.dockerignore
compose.yaml
docker-entrypoint
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:
Adding a local database and persisting data
Configuring Compose to automatically update your running Compose services as you edit and save your code
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
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.
Related information:
Compose file reference
Compose file watch
Multi-stage builds

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:
Containerize a Rust application
Build an image and run the newly built image as a container
Set up volumes and networking
Orchestrate containers using Compose
Use containers for development
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
You have installed the latest version of Docker Desktop.
You have a git client. The examples in this section use a command-line based git client, but you can use any client.
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
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
You have installed the latest version of Docker Desktop.
You have completed the walkthroughs in the Docker Desktop Learning Center to learn about Docker concepts.
You have a git client. The examples in this section use a command-line based git client, but you can use any client.
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.
Clone the sample application repository using the following command.
$ git clone https://github.com/docker/docker-rust-postgres
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"]
In the cloned repository's directory, run 
docker build to build the image.
$ docker build -t rust-backend-image .
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
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:
Uncomment all of the database instructions.
Add the environment variables under the server service.
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.
Related information:
Docker volumes
Compose overview

Testing an ASP.NET Core web app with Testcontainers
In this guide, you'll learn how to:
Use Testcontainers for .NET to spin up a Microsoft SQL Server container for integration tests
Replace SQLite with a production-like database provider in ASP.NET Core tests
Customize 
WebApplicationFactory to configure test dependencies with Testcontainers
Manage container lifecycle with xUnit's 
IAsyncLifetime
Prerequisites
.NET 8.0+ SDK
A code editor or IDE (Visual Studio, VS Code, Rider)
A Docker environment supported by Testcontainers. For details, see the
Testcontainers .NET system requirements.
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:
Microsoft.AspNetCore.Mvc.Testing - provides 
WebApplicationFactory for
bootstrapping the app in tests
Microsoft.EntityFrameworkCore.SqlServer - the SQL Server database provider
for Entity Framework Core
Testcontainers.MsSql - the Testcontainers module for Microsoft SQL Server
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
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:
Removes the existing 
DbContextOptions<ApplicationDbContext> registration
Removes the existing 
DbConnection registration
Adds a new 
ApplicationDbContext configured with the SQL Server connection
string from the Testcontainers-managed container
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
Testcontainers for .NET documentation
Testcontainers for .NET modules
Microsoft SQL Server module
Integration tests in ASP.NET Core

Getting started with Testcontainers for .NET
In this guide, you will learn how to:
Create a .NET solution with a source and test project
Implement a 
CustomerService that manages customer records in PostgreSQL
Write integration tests using Testcontainers and xUnit
Manage container lifecycle with 
IAsyncLifetime
Prerequisites
.NET 8.0+ SDK
A Docker environment supported by Testcontainers
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:
The constructor calls 
CreateCustomersTable() to ensure the table exists.
GetCustomers() fetches all rows from the 
customers table and returns them as 
Customer objects.
Create() inserts a customer record into the database.
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:
Declares a 
PostgreSqlContainer using the 
PostgreSqlBuilder with the
postgres:16-alpine Docker image.
Implements 
IAsyncLifetime for container lifecycle management:
InitializeAsync() starts the container before the test runs.
DisposeAsync() stops and removes the container after the test finishes.
ShouldReturnTwoCustomers() creates a 
CustomerService with connection
details from the container, inserts two customers, fetches all customers, and
asserts the count.
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
Testing an ASP.NET Core web app

Getting started with Testcontainers for Go
In this guide, you will learn how to:
Create a Go application with modules support
Implement a Repository to manage customer data in a PostgreSQL database using the pgx driver
Write integration tests using testcontainers-go
Reuse containers across multiple tests using test suites
Prerequisites
Go 1.25+
Your preferred IDE (VS Code, GoLand)
A Docker environment supported by Testcontainers. For details, see
the testcontainers-go system requirements.
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:
Repository holds a 
*pgx.Conn for performing database operations.
NewRepository(connStr) takes a database connection string and initializes a 
Repository.
CreateCustomer() and 
GetCustomerByEmail() are methods on the 
Repository receiver that insert and query customer records.
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:
Calls 
postgres.Run() with the 
postgres:16-alpine Docker image as the
first argument. This is the v0.41.0 API — the image is a required positional
parameter instead of an option.
Configures initialization scripts using 
WithInitScripts(...) so that the
CUSTOMERS table is created and sample data is inserted after the database
starts.
Uses 
postgres.BasicWaitStrategies() which combines waiting for the Postgres
log message and for the port to be ready. This replaces manual wait strategy
configuration.
Calls 
testcontainers.CleanupContainer(t, ctr) right after 
postgres.Run().
This registers automatic cleanup with the test framework, replacing the manual
t.Cleanup and 
Terminate pattern.
Obtains the database 
ConnectionString from the container and initializes a
Repository.
Creates a customer with the email 
henry@gmail.com and verifies that the
customer exists in the database.
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:
CustomerRepoTestSuite extends 
suite.Suite and includes fields shared
across multiple tests.
SetupSuite() runs once before all tests. It calls
CreatePostgresContainer(suite.T(), ...) which handles cleanup registration
automatically via 
CleanupContainer, so no 
TearDownSuite() is needed.
TestCreateCustomer() uses 
require.NoError() for the create operation
(fail immediately if it errors) and 
assert.NotNil() for the ID check.
TestGetCustomerByEmail() uses 
require.NoError() then asserts on the
returned values.
TestCustomerRepoTestSuite(t *testing.T) runs the test suite when you
execute 
go test.
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
Testcontainers for Go documentation
Testcontainers for Go quickstart
Testcontainers Postgres module for Go

Getting started with Testcontainers for Java
In this guide, you will learn how to:
Create a Java project with Maven
Implement a 
CustomerService that manages customer records in PostgreSQL
Write integration tests using Testcontainers with a real Postgres database
Run the tests and verify everything works
Prerequisites
Java 17+
Maven or Gradle
A Docker environment supported by Testcontainers
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:
The constructor calls 
createCustomersTableIfNotExists() to ensure the table exists.
createCustomer() inserts a customer record into the database.
getAllCustomers() fetches all rows from the 
customers table and returns a list of 
Customer objects.
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:
Declares a 
PostgreSQLContainer with the 
postgres:16-alpine Docker image.
The 
@BeforeAll callback starts the Postgres container before any test
methods run.
The 
@BeforeEach callback creates a 
DBConnectionProvider using the JDBC
connection parameters from the container, then creates a 
CustomerService.
The 
CustomerService constructor creates the 
customers table if it
doesn't exist.
shouldGetCustomers() inserts 2 customer records, fetches all customers,
and asserts the count.
The 
@AfterAll callback stops the container after all test methods finish.
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
Replace H2 with a real database for testing
Getting started with Testcontainers in a Java Spring Boot project

Testcontainers container lifecycle management using JUnit 5
In this guide, you will learn how to:
Start and stop containers using JUnit 5 lifecycle callbacks
Manage containers using JUnit 5 extension annotations (
@Testcontainers and 
@Container)
Share containers across multiple test classes using the singleton containers pattern
Avoid a common misconfiguration when combining extension annotations with singleton containers
Prerequisites
Java 17+
Your preferred IDE
A Docker environment supported by Testcontainers
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:
PostgreSQLContainer is declared as a static field. The container starts
before all tests and stops after all tests in this class.
@BeforeAll starts the container, 
@AfterAll stops it.
@BeforeEach initializes 
CustomerService with the container's JDBC
parameters and deletes all rows to give each test a clean database.
Key observations:
Because the container is a static field, it's shared across all test
methods in the class. You can declare it as a non-static field and use
@BeforeEach/
@AfterEach to start a new container per test, but this
isn't recommended as it's resource-intensive.
Even without explicitly stopping the container in 
@AfterAll, Testcontainers
uses the Ryuk container to
clean up containers automatically when the JVM exits.
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:
The extension finds all 
@Container-annotated fields.
Static fields start once before all tests and stop after all tests.
Instance fields start before each test and stop after each test (not
recommended — it's resource-intensive).
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.
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
Use JUnit 5 lifecycle callbacks (
@BeforeAll/
@AfterAll) for
explicit control over container startup and shutdown.
Use extension annotations (
@Testcontainers/
@Container) for less
boilerplate in single test classes.
Use the singleton containers pattern (static initializer in a base class)
to share containers across multiple test classes.
Don't mix singleton containers with 
@Testcontainers/
@Container
annotations.
Further reading
Testcontainers JUnit 5 quickstart
Testcontainers singleton containers pattern
Testing a Spring Boot REST API with Testcontainers

Replace H2 with a real database for testing
In this guide, you will learn how to:
Understand the drawbacks of using H2 in-memory databases for testing
Replace H2 with a real PostgreSQL database using the Testcontainers special JDBC URL
Use the Testcontainers JUnit 5 extension for more control over the container
Test both Spring Data JPA and JdbcTemplate-based repositories
Prerequisites
Java 17+
Maven or Gradle
A Docker environment supported by Testcontainers
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:
The test database might not support all features of your production database.
SQL syntax might not be compatible between H2 and your production database.
Tests passing with H2 don't guarantee they'll work in production.
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:
Uses 
@Testcontainers and 
@Container to manage the container lifecycle.
Copies 
init-db.sql into the container's init directory so PostgreSQL
runs it at startup.
Uses 
@DynamicPropertySource to register the container's connection details
with Spring Boot.
Tests PostgreSQL-specific features like 
ON CONFLICT DO NOTHING that
wouldn't work with H2.
Summary
Use the special JDBC URL (
jdbc:tc:postgresql:...) for the quickest way
to switch from H2 to a real database — it's a one-property change.
Use the JUnit 5 extension when you need more control over the container
(custom init scripts, environment variables, etc.).
Both approaches work with Spring Data JPA (
@DataJpaTest) and JdbcTemplate
(
@JdbcTest) tests.
Further reading
Testcontainers Postgres module
Testcontainers JDBC support
Testing a Spring Boot REST API with Testcontainers

Configuration of services running in a container
In this guide, you will learn how to:
Initialize containers by copying files into them
Run commands inside running containers using 
execInContainer()
Set up a PostgreSQL database with SQL scripts
Create AWS S3 buckets in LocalStack containers
Prerequisites
Java 17+
Your preferred IDE
A Docker environment supported by Testcontainers
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();
The 
withCopyFileToContainer() and 
execInContainer() methods are inherited
from 
GenericContainer, so they're available for all Testcontainers modules.
Summary
Use 
withCopyFileToContainer() to place initialization files inside
containers before they start.
Use 
execInContainer() to run commands inside running containers for
setup tasks like creating buckets, topics, or queues.
Further reading
Getting started with Testcontainers for Java
Testcontainers Postgres module
Testcontainers LocalStack module

Testing a Spring Boot REST API with Testcontainers
In this guide, you will learn how to:
Create a Spring Boot application with a REST API endpoint
Use Spring Data JPA with PostgreSQL to store and retrieve data
Test the REST API using Testcontainers and REST Assured
Prerequisites
Java 17+
Maven or Gradle
A Docker environment supported by Testcontainers
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:
@SpringBootTest starts the full application on a random port.
A 
PostgreSQLContainer starts in 
@BeforeAll and stops in 
@AfterAll.
@DynamicPropertySource registers the container's JDBC URL, username, and
password with Spring so that the datasource connects to the test container.
@BeforeEach deletes all customer rows before each test to prevent test
pollution.
shouldGetAllCustomers() inserts two customers, calls 
GET /api/customers,
and verifies the response contains 2 records.
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
Testcontainers JUnit 5 quickstart
Testcontainers Postgres module
Testcontainers JDBC support

Getting started with Testcontainers for Node.js
In this guide, you will learn how to:
Create a Node.js application that stores and retrieves customers from PostgreSQL
Write integration tests using Testcontainers and Jest
Run tests against a real PostgreSQL database in a Docker container
Prerequisites
Node.js 18+
npm
A Docker environment supported by Testcontainers
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:
createCustomerTable() creates the 
customers table if it doesn't exist.
createCustomer() inserts a customer record.
getCustomers() fetches all customer records.
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 
beforeAll block starts a real PostgreSQL container using
PostgreSqlContainer. It then creates a 
pg client connected to the
container and sets up the 
customers table.
The 
afterAll block closes the client connection and stops the container.
The test inserts two customers, fetches all customers, and asserts the
results match.
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
Testcontainers for Node.js documentation

Getting started with Testcontainers for Python
In this guide, you will learn how to:
Create a Python application that uses PostgreSQL to store customer data
Use 
psycopg to interact with the database
Write integration tests using 
testcontainers-python and 
pytest
Manage container lifecycle with pytest fixtures
Prerequisites
Python 3.10+
pip
A Docker environment supported by Testcontainers
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()
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:
The 
setup fixture has 
scope="module", so it runs once for all tests in
the file. It starts a PostgreSQL container, sets environment variables with
the connection details, and creates the 
customers table. A cleanup
function removes the container after all tests complete.
The 
setup_data fixture has 
scope="function", so it runs before every
test. It deletes all records to give each test a clean database.
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"
test_get_all_customers() inserts two customer records, fetches all
customers, and asserts the count.
test_get_customer_by_email() inserts a customer, fetches it by email, and
asserts the details.
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
testcontainers-python documentation
Getting started with Testcontainers for Go
Getting started with Testcontainers for Java
Getting started with Testcontainers for Node.js

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:
Start Traefik with Docker
Configure routing rules to split traffic between two containers
Use Traefik in a containerized development environment
Use Traefik to send requests to non-containerized workloads
Prerequisites
The following prerequisites are required to follow along with this how-to guide:
Docker Desktop
Node.js and yarn
Basic of Docker
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:
traefik.http.routers.<service-name>.rule - used to indicate the routing rule (view all of the available rules here)
traefik.http.services.<service-name>.loadbalancer.server.port - indicates the port Traefik should forward the request to. Note that this container port does not need to be exposed on your host machine (read about port detection here)
Let’s do a quick demo of starting Traefik and then configuring two additional containers to be accessible using different hostnames.
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
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 >}}
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).
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:
All requests to /api to go to the API service
All other requests to localhost go to the frontend client
Since the app uses MySQL, db.localhost should provide phpMyAdmin to make it easy to access the database during development
The application can be accessed on GitHub at dockersamples/easy-http-routing-with-traefik.
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.
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 >}}
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`)"
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
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.
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:
The configuration file is mounted into the Traefik container (the exact destination path is up to you)
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:
If you have the Compose stack still running, stop it with the following command:
$ docker compose down
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.
Start the API by running the following steps:
cd api
yarn install
yarn dev
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
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.
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:
Containerize and run an Vue.js application using Docker.
Set up a local development environment for Vue.js inside a container.
Run tests for your Vue.js application within a Docker container.
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:
Basic understanding of TypeScript and JavaScript.
Familiarity with Node.js and npm for managing dependencies and running scripts.
Familiarity with Vue.js fundamentals.
Understanding of core Docker concepts such as images, containers, and Dockerfiles. If you're new to Docker, start with the Docker basics guide.
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:
You have installed the latest version of Docker Desktop.
You have a git client. The examples in this section use a command-line based git client, but you can use any client.
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:
Containerize an Vue.js application using Docker.
Create and optimize a Dockerfile for production builds.
Use multi-stage builds to minimize image size.
Serve the application efficiently with a custom Nginx configuration.
Build secure and maintainable Docker images by following best practices.
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.
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.
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.
Sign in to the DHI registry:
$ docker login dhi.io
Pull the Node.js DHI (check the catalog for available versions):
$ docker pull dhi.io/node:24-alpine3.22-dev
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;"]
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:
Reduces the attack surface
Aligns with Docker’s recommendations for container hardening
Helps comply with stricter security policies in production environments
{{< /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.
This helps:
Reduce image size
Speed up the build process
Prevent sensitive or unnecessary files (like 
.env, 
.git, or 
node_modules) from being added to the final image.
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:
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:
The updated setup includes a clean, production-ready Nginx configuration tailored specifically for Vue.js.
Efficient multi-stage Docker build, ensuring a small and secure final image.
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.
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:
Uses the Dockerfile in the current directory (.)
Packages the application and its dependencies into a Docker image
Tags the image as docker-vuejs-sample so you can reference it later
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:
Repository – The name assigned to the image.
Tag – A version label that helps identify different builds (e.g., latest).
Image ID – A unique identifier for the image.
Created – The timestamp indicating when the image was built.
Size – The total disk space used by the image.
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
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:
Configure isolated environments: Set up separate containers optimized for production and development use cases.
Live-reload in development: Use Compose Watch to automatically sync file changes, enabling real-time updates without manual intervention.
Preview and debug with ease: Develop inside containers with a seamless preview and debug experience—no rebuilds required after every change.
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
The 
vuejs-prod service builds and serves your static production app using Nginx.
The 
vuejs-dev service runs your Vue.js development server with live reload and hot module replacement.
watch triggers file sync with Compose Watch.
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:
Open the 
src/App.vue file in your text editor.
Locate the following line:
<HelloWorld msg="You did it!" />
Change it to:
<HelloWorld msg="Hello from Docker Compose Watch" />
Save the file.
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:
Run unit tests using Vitest inside a Docker container.
Use Docker Compose to run tests in an isolated, reproducible environment.
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:
Start the 
vuejs-test service defined in your 
compose.yaml file.
Execute the 
npm run test script using the same environment as development.
Automatically remove the container after the tests complete 
docker compose run --rm command.
You should see output similar to the following:
Test Files: 1 passed (1)
Tests:      1 passed (1)
Start at:   16:50:55
Duration:   718ms
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:
Created a 
vuejs-test service in 
compose.yaml to isolate test execution.
Reused the development 
Dockerfile.dev to ensure consistency between dev and test environments.
Ran tests inside the container using 
docker compose run --rm vuejs-test.
Ensured reliable, repeatable testing across environments without depending on your local machine setup.
Related resources
Explore official references and best practices to sharpen your Docker testing workflow:
Dockerfile reference – Understand all Dockerfile instructions and syntax.
Best practices for writing Dockerfiles – Write efficient, maintainable, and secure Dockerfiles.
Compose file reference – Learn the full syntax and options available for configuring services in 
compose.yaml.
docker compose run CLI reference – Run one-off commands in a service container.

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:
Use Docker to launch up a WireMock container.
Use mock data in the local development without relying on an external API
Use a Live API in production to fetch real-time weather data from AccuWeather.
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:
Docker Desktop
Launching WireMock
Launch a quick demo of WireMock by using the following steps:
Clone the GitHub repository locally.
$ git clone https://github.com/dockersamples/wiremock-node-docker
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.
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.
You can check the logs by selecting the 
wiremock-node-docker container:
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:
Local Development Environment: The context in which the Node.js backend and WireMock are running.
Node.js Backend: Represents the backend application that handles HTTP requests.
External AccuWeather API: The real API from which live weather data is fetched.
WireMock: The mock server that simulates the API responses during testing. It runs as a Docker container.
In development, the Node.js backend sends a request to WireMock instead of the actual AccuWeather API.
In production, it connects directly to the live AccuWeather API for real data.
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
Install Node.js and npm
Ensure that WireMock container is up and running (see Launching Wiremock
Follow the steps to setup a non-containerized Node application:
Navigate to the 
accuweather-api directory
Make sure you're in the directory where your 
package.json file is located.
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.
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;
}
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.
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.
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.
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.
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.
Change directory to 
accuweather-api
$ cd accuweather-api
Set your AccuWeather API key using the 
.env file:
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.
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.
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.
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
In Google Chrome, navigate to 
chrome://certificate-manager/.
Under Local certificates, select View imported certificates.
Find the Zscaler root certificate, often labeled Zscaler Root CA.
Open the certificate details and select Export.
Save the certificate in ASCII PEM format.
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.