Guides
- Build and run agentic AI applications with Docker
- Angular language-specific guide
- Introduction to Azure Pipelines with Docker
- Bun language-specific guide
- Use Claude Code with Docker Model Runner
- Run Claude Code in a Docker Sandbox with Docker Model Runner
- C++ language-specific guide
- Use containerized databases
- Deno language-specific guide
- Mocking OAuth services in testing with Dex
- Secure a Backstage application with Docker Hardened Images
- Use Docker Hardened Images with Red Hat OpenShift
- Explore VEX statements in Docker Hardened Images
- Containerize a Django application
- .NET language-specific guide
- Give sandboxed agents live web access with Firecrawl
- Generate Docker Compose Files with Claude Code and Docker MCP Toolkit
- Monitor a Golang application with Prometheus and Grafana
- Go language-specific guide
- Connect Gemini to Grafana via MCP
- Java language-specific guide
- Data science with JupyterLab
- Developing event-driven applications with Kafka and Docker
- Lab: Building Agentic Apps with Docker
- Lab: AI Fundamentals for Developers
- Lab: Container Image Attestations
- Lab: Building Container Images
- Lab: Docker Compose Quickstart
- Lab: Getting Started with Docker
- Lab: Container-Supported Development
- Lab: The Containerized SDLC
- Lab: Building an AI Product Reviewer
- Lab: Migrating a Node App to Docker Hardened Images
- Lab: Getting Started with Docker Agent
- Lab: Docker MCP Gateway
- Develop and Deploy Laravel applications with Docker Compose
- Develop and test AWS Cloud applications using LocalStack and Docker
- Run Mistral Vibe in a Docker Sandbox
- Containerize a Next.js application
- Node.js language-specific guide
- Use OpenCode with Docker Model Runner
- Instrumenting a JavaScript App with OpenTelemetry
- Visualizing your PostgreSQL databases with pgAdmin
- PHP language-specific guide
- PostgreSQL specific guide
- Pre-seeding database with schema and data at startup for development environment
- Python language-specific guide
- R language-specific guide
- React.js language-specific guide
- Introduction to ROS 2 Development with Docker
- Ruby on Rails language-specific guide
- Rust language-specific guide
- Testing an ASP.NET Core web app with Testcontainers
- Getting started with Testcontainers for .NET
- Getting started with Testcontainers for Go
- Getting started with Testcontainers for Java
- Testcontainers container lifecycle management using JUnit 5
- Replace H2 with a real database for testing
- Configuration of services running in a container
- Testing a Spring Boot REST API with Testcontainers
- Getting started with Testcontainers for Node.js
- Getting started with Testcontainers for Python
- HTTP routing with Traefik
- Vue.js language-specific guide
- Mocking API services in development and testing with WireMock
- Using Docker with Zscaler
Build and run agentic AI applications with Docker
[!TIP]
This guide uses the familiar Docker Compose workflow to orchestrate agentic AI applications. For a smoother development experience, check out Docker Agent, a purpose-built agent runtime that simplifies running and managing AI agents.
Introduction
Agentic applications are transforming how software gets built. These apps don't just respond, they decide, plan, and act. They're powered by models, orchestrated by agents, and integrated with APIs, tools, and services in real time.
All these new agentic applications, no matter what they do, share a common architecture. It's a new kind of stack, built from three core components:
-
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 upThe 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
adkservice, which is the web application that runs the agentic AI application. This service talks to the MCP gateway and model. - The
mcp-gatewayservice, which is the MCP gateway that connects the app to external tools and services. - The
modelsblock, 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
modelsblock in theadkservice - A top-level
modelsblock
These two blocks together let Docker Compose automatically start and connect your ADK web app to the specified LLM.
[!TIP]
Looking for more models to use? Check out the Docker AI Model Catalog.
When examining the compose.yaml file, you'll notice the gateway service is a
Docker-maintained image,
docker/mcp-gateway:latest.
This image is Docker's open source MCP
Gateway that enables your application to
connect to MCP servers, which expose tools that models can call. In this
example, it uses the duckduckgo MCP
server to perform web
searches.
[!TIP]
Looking for more MCP servers to use? Check out the Docker MCP Catalog.
With only a few lines of instructions in a Compose file, you're able to run and connect all the necessary services of an agentic AI application.
In addition to the Compose file, the Dockerfile and the
entrypoint.sh script it creates, play a role in wiring up the AI stack at build and
runtime. You can find the Dockerfile in the adk/ directory. Open it in a
text editor.
# Use Python 3.11 slim image as base
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
RUN pip install uv
WORKDIR /app
# Install system dependencies
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy \
uv pip install --system .
# Copy application code
COPY agents/ ./agents/
RUN python -m compileall -q .
COPY <<EOF /entrypoint.sh
#!/bin/sh
set -e
if test -f /run/secrets/openai-api-key; then
export OPENAI_API_KEY=$(cat /run/secrets/openai-api-key)
fi
if test -n "\${OPENAI_API_KEY}"; then
echo "Using OpenAI with \${OPENAI_MODEL_NAME}"
else
echo "Using Docker Model Runner with \${MODEL_RUNNER_MODEL}"
export OPENAI_BASE_URL=\${MODEL_RUNNER_URL}
export OPENAI_MODEL_NAME=openai/\${MODEL_RUNNER_MODEL}
export OPENAI_API_KEY=cannot_be_empty
fi
exec adk web --host 0.0.0.0 --port 8080 --log_level DEBUG
EOF
RUN chmod +x /entrypoint.sh
# Create non-root user
RUN useradd --create-home --shell /bin/bash app \
&& chown -R app:app /app
USER app
ENTRYPOINT [ "/entrypoint.sh" ]
The entrypoint.sh has five key environment variables:
-
MODEL_RUNNER_URL: Injected by Compose (via the service-levelmodels: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 anopenai-api-keysecret in your Compose file, Compose will mount it at/run/secrets/openai-api-key. The entrypoint script reads that file and exports it asOPENAI_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 toMODEL_RUNNER_URLso 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 withopenai/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 optionallyOPENAI_MODEL_NAME) - Model Runner: by remapping
MODEL_RUNNER_URLandMODEL_RUNNER_MODELinto 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 parsingmcp/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.
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored to your application.
Step 1: Create the Dockerfile
Before creating a Dockerfile, you need to choose a base image. You can either use the Node.js Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog.
Choosing DHI offers the advantage of a production-ready image that is lightweight and secure. For more information, see Docker Hardened Images.
[!IMPORTANT] This guide uses a stable Node.js LTS image tag that is considered secure when the guide is written. Because new releases and security patches are published regularly, the tag shown here may no longer be the safest option when you follow the guide. Always review the latest available image tags and select a secure, up-to-date version before building or deploying your application.
Official Node.js Docker Images: https://hub.docker.com/_/node
{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}} Docker Hardened Images (DHIs) are available for Node.js in the Docker Hardened Images catalog. Docker Hardened Images are freely available to everyone with no subscription required. You can pull and use them like any other Docker image after signing in to the DHI registry. For more information, see the DHI quickstart guide.
-
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;"]
[!NOTE] We are using nginx-unprivileged instead of the standard Nginx image to follow security best practices. Running as a non-root user in the final image:
- 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.
[!NOTE] This helps:
- Reduce image size
- Speed up the build process
- Prevent sensitive or unnecessary files (like
.env,.git, ornode_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:
[!NOTE] To learn more about configuring Nginx, see the official Nginx documentation.
worker_processes auto;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
# Logging
access_log off;
error_log /dev/stderr warn;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000;
# Compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 256;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/xml+rss
font/ttf
font/otf
image/svg+xml;
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Angular Routing
location / {
try_files $uri $uri/ /index.html;
}
# Static Assets Caching
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|map)$ {
expires 1y;
access_log off;
add_header Cache-Control "public, immutable";
}
# Optional: Explicit asset route
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
Step 5: Build the Angular application image
With your custom configuration in place, you're now ready to build the Docker image for your Angular application.
The updated setup includes:
- 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.
[!NOTE] The
docker buildcommand 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
[!NOTE] For more information about Compose commands, see the Compose CLI reference.
Use containers for Angular development
Prerequisites
Complete Containerize Angular application.
Overview
In this section, you'll learn how to set up both production and development environments for your containerized Angular application using Docker Compose. This setup allows you to serve a static production build via Nginx and to develop efficiently inside containers using a live-reloading dev server with Compose Watch.
You’ll learn how to:
- 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-prodservice builds and serves your static production app using Nginx. - The
angular-devservice runs your Angular development server with live reload and hot module replacement. watchtriggers file sync with Compose Watch.
[!NOTE] For more details, see the official guide: Use Compose Watch.
After completing the previous steps, your project directory should now contain the following files:
├── docker-angular-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf
Step 4: Start Compose Watch
Run the following command from the project root to start the container in watch mode
$ docker compose watch angular-dev
Step 5: Test Compose Watch with Angular
To verify that Compose Watch is working correctly:
-
Open the
src/app/app.component.htmlfile 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-testservice defined in yourcompose.yamlfile. - Execute the
npm run testscript using the same environment as development. - Automatically removes the container after tests complete, using the
docker compose run --rmcommand.
You should see output similar to the following:
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 1.529 s
[!NOTE] For more information about Compose commands, see the Compose CLI reference.
Summary
In this section, you learned how to run unit tests for your Angular application inside a Docker container using Jasmine and Docker Compose.
What you accomplished:
- Created a
angular-testservice incompose.yamlto isolate test execution. - Reused the development
Dockerfile.devto 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 runCLI 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
Dockerfileat 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:
[!IMPORTANT]
Avoid selecting the option to grant access to all pipelines unless absolutely necessary. Always apply the principle of least privilege.
Step 2: Create your pipeline
Add the following azure-pipelines.yml file to the root of your repository:
# Trigger pipeline on commits to the main branch
trigger:
- main
# Trigger pipeline on pull requests targeting the main branch
pr:
- main
# Define variables for reuse across the pipeline
variables:
imageName: 'docker.io/$(dockerUsername)/my-image'
buildTag: '$(Build.BuildId)'
latestTag: 'latest'
stages:
- stage: BuildAndPush
displayName: Build and Push Docker Image
jobs:
- job: DockerJob
displayName: Build and Push
pool:
vmImage: ubuntu-latest
demands:
- docker
steps:
- checkout: self
displayName: Checkout Code
- task: Docker@2
displayName: Docker Login
inputs:
command: login
containerRegistry: 'my-docker-registry' # Service connection name
- task: Docker@2
displayName: Build Docker Image
inputs:
command: build
repository: $(imageName)
tags: |
$(buildTag)
$(latestTag)
dockerfile: './Dockerfile'
arguments: |
--sbom=true
--attest type=provenance
--cache-from $(imageName):latest
env:
DOCKER_BUILDKIT: 1
- task: Docker@2
displayName: Push Docker Image
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
inputs:
command: push
repository: $(imageName)
tags: |
$(buildTag)
$(latestTag)
# Optional: logout for self-hosted agents
- script: docker logout
displayName: Docker Logout (Self-hosted only)
condition: ne(variables['Agent.OS'], 'Windows_NT')
What this pipeline does
This pipeline automates the Docker image build and deployment process for the main branch. It ensures a secure and efficient workflow with best practices like caching, tagging, and conditional cleanup. Here's what it does:
- Triggers on commits and pull requests targeting the
mainbranch. - 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
mainbranch - Pull requests targeting
mainmain branch
[!TIP] Learn more: Define pipeline triggers in Azure Pipelines
Step 2: Define common variables
variables:
imageName: 'docker.io/$(dockerUsername)/my-image'
buildTag: '$(Build.BuildId)'
latestTag: 'latest'
These variables ensure consistent naming, versioning, and reuse throughout the pipeline steps:
imageName: your image path on Docker HubbuildTag: a unique tag for each pipeline runlatestTag: a stable alias for your most recent image
[!IMPORTANT]
The variable
dockerUsernameis not set automatically.
Set it securely in your Azure DevOps pipeline variables:
- Go to Pipelines > Edit > Variables
- Add
dockerUsernamewith your Docker Hub usernameLearn more: Define and use variables in Azure Pipelines
Step 3: Define pipeline stages and jobs
stages:
- stage: BuildAndPush
displayName: Build and Push Docker Image
This stage builds the image on both commit and pull request runs. The push task below runs only when the source branch is main.
[!TIP]
Learn more: Stage conditions in Azure Pipelines
Step 4: Job configuration
jobs:
- job: DockerJob
displayName: Build and Push
pool:
vmImage: ubuntu-latest
demands:
- docker
This job utilizes the latest Ubuntu VM image with Docker support, provided by Microsoft-hosted agents. It can be replaced with a custom pool for self-hosted agents if necessary.
[!TIP]
Learn more: Specify a pool in your pipeline
Step 4.1: Checkout code
steps:
- checkout: self
displayName: Checkout Code
This step pulls your repository code into the build agent, so the pipeline can access the Dockerfile and application files.
[!TIP]
Learn more: checkout step documentation
Step 4.2: Authenticate to Docker Hub
- task: Docker@2
displayName: Docker Login
inputs:
command: login
containerRegistry: 'my-docker-registry' # Replace with your service connection name
Uses a pre-configured Azure DevOps Docker registry service connection to authenticate securely without exposing credentials directly.
[!TIP]
Learn more: Use service connections for Docker Hub
Step 4.3: Build the Docker image
- task: Docker@2
displayName: Build Docker Image
inputs:
command: build
repository: $(imageName)
tags: |
$(buildTag)
$(latestTag)
dockerfile: './Dockerfile'
arguments: |
--sbom=true
--attest type=provenance
--cache-from $(imageName):latest
env:
DOCKER_BUILDKIT: 1
This builds the image with:
[!TIP]
Learn more:
Step 4.4: Push the Docker image
- task: Docker@2
displayName: Push Docker Image
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
inputs:
command: push
repository: $(imageName)
tags: |
$(buildTag)
$(latestTag)
By applying this condition, the pipeline builds the Docker image on every run to ensure early detection of issues, but only pushes the image to the registry when changes are merged into the main branch—keeping your Docker Hub clean and focused
This uploads both tags to Docker Hub:
$(buildTag)ensures traceability per run.latestis 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
/appdirectory 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.
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-osswith 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
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-osswith 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
localhostdirectly. - 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.internaltolocalhost.
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:latestimage to compile the C++ code and create a static binary. - Runtime stage: This stage uses the
scratchimage, 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 .
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:
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.
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-mysqlassigns the name my-mysql to your container for easier reference.-e MYSQL_ROOT_PASSWORD=my-secret-pwsets the root password for MySQL to my-secret-pw. Replace my-secret-pw with a secure password of your choice.-e MYSQL_DATABASE=mydboptionally creates a database named mydb. You can change mydb to your desired database name.-druns the container in detached mode, meaning it runs in the background.mysql:latestspecifies 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
mysqlin the search box, and select theImagestab if not already selected. -
Hover over the
mysqlimage and selectRun. 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-pwMYSQL_DATABASE:mydb

- Container name:
-
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 exectells Docker you want to execute a command in a running container.-itensures that the terminal you're accessing is interactive, so you can type commands into it.my-mysqlis the name of your MySQL container. If you named your container differently when you ran it, use that name instead.bashis 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-mysqlto remove the container namedmy-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
mysqlin the search box, and select theImagestab if not already selected. -
Hover over the
mysqlimage and selectRun. 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-pwMYSQL_DATABASE:mydb

- Container name:
-
Select
Run. -
In the Containers view, verify that the port is mapped under the Port(s) column. You should see
3307:3306for themy-mysqlcontainer.
{{< /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-mysqlto remove the container namedmy-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
--networkoption. 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
--networkoption. For this example, you'll run a phpMyAdmin container that can connect to your database.-
Run a phpMyAdmin container. Use the
--networkoption to specify the network, the-poption to let you access the container from your host machine, and the-eoption 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
rootas the username andmy-secret-pwas 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-mysqlto remove the container namedmy-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:latestThis command mounts the volume named
my-db-volumeto the/var/lib/mysqldirectory in the container. -
Create some data in the database. Use the
docker execcommand to runmysqlinside 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
mysqltool in the container to create a table namedmytablewith a column namedcolumn_name, and finally inserts a value ofvalue. -
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 execcommand again to runmysqlinside the container.$ docker exec my-mysql mysql -u root -pmy-secret-pw -e "SELECT * FROM mydb.mytable;"This command uses the
mysqltool in the container to select all the records from themytabletable.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
mysqlin 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-pwMYSQL_DATABASE:mydb
- Volumes:
my-db-volume:/var/lib/mysql

Here, the name of the volume is
my-db-volumeand it is mounted in the container at/var/lib/mysql. - Container name:
-
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
mysqltool in the container to create a table namedmytablewith a column namedcolumn_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
mysqlin 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-pwMYSQL_DATABASE:mydb
- Volumes:
my-db-volume:/var/lib/mysql

- Container name:
-
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
mysqltool in the container to select all the records from themytabletable.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-mysqlto remove the container namedmy-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
Dockerfilein your project directory. For this example, you can create theDockerfilein 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
COPYinstruction 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.gzscripts 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
Dockerfileis located, create a subdirectory namedscripts, and then create a file namedcreate_table.sqlwith 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
Dockerfileis located. -
Run the following command to build the image.
$ docker build -t my-custom-mysql .In this command,
-t my-custom-mysqltags (names) your new image asmy-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 theMYSQL_DATABASEenvironment 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 psYou 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
myothertabletable.$ 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.yamlin your project directory. This file will define the services, networks, and volumes. -
Add the following content to the
compose.yamlfile.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:
dbis the name of the service.image: mysql:latestspecifies that the service uses the latest MySQL image from Docker Hub.environmentlists the environment variables used by MySQL to initialize the database, such as the root password and the database name.portsmaps port 3307 on the host to port 3306 in the container, allowing you to connect to the database from your host machine.volumesmountsmy-db-volumeto/var/lib/mysqlinside 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_HOSTenvironment 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.yamlfile is located. -
Run Docker Compose using the following command.
$ docker compose upYou can now access phpMyAdmin at http://localhost:8080 and connect to your database using
rootas the username andmy-secret-pwas the password. -
To stop the containers, press
ctrl+cin 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.
- 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.tsinto the container. - Sets the user to
denoto run the application as a non-root user. - Exposes port 8000 to allow traffic to the application.
- Runs the Deno server using the
CMDinstruction. - Uses the
--allow-netflag to allow network access to the application. Theserver.tsfile 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.
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.
[!NOTE]
Ensure the hash is a valid bcrypt hash of your desired password. You can generate this using tools like bcrypt-generator.com. or use CLI tools like htpasswd like in this following example:
echo password | htpasswd -BinC 10 admin | cut -d: -f2
With Docker Compose configured, start Dex:
# compose.yaml
services:
dex:
image: dexidp/dex:latest
container_name: dex
ports:
- "5556:5556"
volumes:
- ./config.yaml:/etc/dex/config.yaml
command: ["dex", "serve", "/etc/dex/config.yaml"]
Now it is possible to run the container using the docker compose command.
docker compose up -d
This command will download the Dex Docker image (if not already available) and start the container in detached mode.
To verify that Dex is running, check the logs to ensure Dex started successfully:
docker compose logs -f dex
You should see output indicating that Dex is listening on the specified port.
Using Dex OAuth testing in GHA
To test the OAuth flow, you'll need a client application configured to authenticate against Dex. One of the most typical use cases is to use it inside GitHub Actions. Since Dex supports mock authentication, you can predefine test users as suggested in the docs. The config.yaml file should looks like:
issuer: http://127.0.0.1:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
oauth2:
skipApprovalScreen: true
staticClients:
- name: TestClient
id: client_test_id
secret: client_test_secret
redirectURIs:
- http://{ip-your-app}/path/to/callback/ # example: http://localhost:5555/callback
connectors:
# mockCallback connector always returns the user 'kilgore@kilgore.trout'.
- type: mockCallback
id: mock
name: Mock
Now you can insert the Dex service inside your ~/.github/workflows/ci.yaml file:
[...]
jobs:
test-oauth:
runs-on: ubuntu-latest
steps:
- name: Install Dex
run: |
curl -L https://github.com/dexidp/dex/releases/download/v2.37.0/dex_linux_amd64 -o dex
chmod +x dex
- name: Start Dex Server
run: |
nohup ./dex serve config.yaml > dex.log 2>&1 &
sleep 5 # Give Dex time to start
[...]
Conclusion
By following this guide, you've set up Dex as an OAuth mock server using Docker. This setup is invaluable for testing and development, allowing you to simulate OAuth flows without relying on external identity providers. For more advanced configurations and integrations, refer to the Dex documentation.
Secure a Backstage application with Docker Hardened Images
This guide shows how to secure a Backstage application using Docker Hardened Images (DHI). Backstage is a CNCF open source developer portal used by thousands of organizations to manage their software catalogs, templates, and developer tooling.
By the end of this guide, you'll have a Backstage container image that is distroless, runs as a non-root user by default, and has dramatically fewer CVEs than the standard node:24-trixie-slim base image while still supporting the native module compilation that Backstage requires.
Prerequisites
- Docker Desktop or Docker Engine with BuildKit enabled
- A Docker Hub account authenticated with
docker loginanddocker 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 .
[!NOTE]
The
-devvariant includes a shell and package manager, which is whyapk addworks. Backstage requirespython3and native build tools in the runtime image becauseyarn workspaces focus --all --productionrecompiles native modules during the production install. This is specific to Backstage's build process — most Node.js applications can use the standard (non-dev) DHI runtime variant without additional packages.
The DHI images come with attestations that the original node:24-trixie-slim images don't have. Check what's attached:
docker scout attest list dhi.io/node:24-alpine3.23
DHI images ship with 15 attestations including CycloneDX SBOM, SLSA provenance, OpenVEX, Scout health reports, secret scans, virus/malware reports, and an SLSA verification summary.
Step 3: Add Socket Firewall protection
DHI provides -sfw (Socket Firewall) variants for Node.js images. Socket Firewall intercepts npm and yarn commands during the build to detect and block malicious packages before they execute install scripts.
To enable Socket Firewall, change the -dev tags to -sfw-dev in all three stages. The SFW version of the Dockerfile:
# Stage 1: prepare packages
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-sfw-dev AS packages
WORKDIR /app
COPY backstage.json package.json yarn.lock ./
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY packages packages
COPY plugins plugins
RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 \
-exec rm -rf {} \+
# Stage 2: build the packages
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-sfw-dev AS build-packages
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=packages --chown=node:node /app .
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
yarn install --immutable
COPY --chown=node:node . .
RUN yarn tsc
RUN yarn --cwd packages/backend build
RUN mkdir packages/backend/dist/skeleton packages/backend/dist/bundle \
&& tar xzf packages/backend/dist/skeleton.tar.gz \
-C packages/backend/dist/skeleton \
&& tar xzf packages/backend/dist/bundle.tar.gz \
-C packages/backend/dist/bundle
# Final Stage: create the runtime image
FROM dhi.io/node:24-alpine3.23-sfw-dev
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=build-packages --chown=node:node /app/.yarn ./.yarn
COPY --from=build-packages --chown=node:node /app/.yarnrc.yml ./
COPY --from=build-packages --chown=node:node /app/backstage.json ./
COPY --from=build-packages --chown=node:node /app/yarn.lock \
/app/package.json \
/app/packages/backend/dist/skeleton/ ./
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
yarn workspaces focus --all --production \
&& rm -rf "$(yarn cache clean)"
COPY --from=build-packages --chown=node:node /app/packages/backend/dist/bundle/ ./
CMD ["node", "packages/backend", "--config", "app-config.yaml"]
Build this version:
docker build -t backstage:dhi-sfw-dev .
When you build, you'll see Socket Firewall messages in the build output: Protected by Socket Firewall for any yarn and npm commands executed in the Dockerfile or in the running containers.
[!TIP]
The
-sfw-devvariant is larger (1.9 GB versus 1.72 GB) because Socket Firewall adds monitoring tooling. The security benefit duringyarn installoutweighs the size increase.
Step 4: Remove the shell and the package manager with DHI customizations
The previous steps still use the -dev or -sfw-dev variant as the runtime image, which includes a shell and package manager. DHI customizations let you start from the base (non-dev) image — which has no shell and no package manager — and add only the runtime libraries and language runtimes your application needs.
[!IMPORTANT]
When creating a customization, only add what your application needs at runtime:
- System packages - add shared libraries (such as
sqlite-libs) and language runtimes from the DHI catalog (such aspython-3.14). Do not add build tools (such asg++,make, orpython3from Alpine).- Build tools - keep these in the
-devbuild 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-sqlite3native module links against (added as a system package). - Python - if your Backstage plugins or configuration require Python at runtime. Added as the
python-3.14system package from the DHI catalog. Unlikepython3installed viaapk, 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.23tag. - Under Packages, add
sqlite-libsandpython-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.
[!NOTE]
If your Backstage configuration does not require Python at runtime, you can omit the
python-3.14from the packages list. Thesqlite-libspackage alone is sufficient to run Backstage withbetter-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.
[!NOTE]
If your organization requires FIPS/STIG compliant images, that's also an option in DHI Enterprise.
Step 5: Verify the results
Compare the DHI-based image against the original using Docker Scout:
docker scout compare backstage:dhi \
--to backstage:init \
--platform linux/amd64 \
--ignore-unchanged
A typical comparison across the approaches shows results similar to the following:
| Metric | Original | DHI -dev | DHI -sfw-dev | Enterprise |
|---|---|---|---|---|
| Disk usage | 1.61 GB | 1.72 GB | 1.9 GB | 1.49 GB |
| Content size | 268 MB | 288 MB | 328 MB | 247 MB |
| Shell in runtime | Yes | Yes | Yes | No |
| Package manager | Yes | Yes | Yes | No |
| Non-root default | No | No | No | Yes |
| Socket Firewall | No | No | Yes (build) | Yes (build) / No (runtime) |
| SLSA provenance | No | Base only | Base only | Full (Level 3) |
[!NOTE]
The
-sfw-devvariant is larger because Socket Firewall adds monitoring tooling to the image. The additional size is in the build stages, and the security benefit duringyarn installoutweighs 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.
[!IMPORTANT]
DHI images set file ownership to
nonroot:nonroot(65532:65532) by default. Because the OpenShift arbitrary UID is NOT in thenonrootgroup (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
-devvariant 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
[!IMPORTANT]
Always use
--chown=<UID>:0(user:root-group) when copying files into the runtime stage. This ensures the arbitrary UID that OpenShift assigns can access the files through root group membership. Never useRUNin the runtime stage — distroless DHI images have no shell.
[!NOTE]
The UID for DHI images varies by image. Most use 65532 (
nonroot), but some (like the Node.js image) may use a different UID. Verify with:docker inspect dhi.io/<image>:<tag> --format '{{.Config.User}}'
Deploy to OpenShift:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-dhi
spec:
replicas: 1
selector:
matchLabels:
app: nginx-dhi
template:
metadata:
labels:
app: nginx-dhi
spec:
containers:
- name: nginx
image: YOUR_ORG/dhi-nginx:1.29-alpine3.23
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
capabilities:
drop:
- ALL
imagePullSecrets:
- name: dhi-pull-secret
DHI Nginx listens on port 8080 by default (not 80), which is compatible with the non-root requirement. No SCC changes are needed.
Example: Node.js application for OpenShift
# Build stage — dev variant has shell and npm
FROM YOUR_ORG/dhi-node:24-alpine3.23-dev AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Set GID 0 on everything the runtime needs to write
RUN chgrp -R 0 /app/dist /app/node_modules && \
chmod -R g=u /app/dist /app/node_modules
# Runtime stage — distroless, NO shell
FROM YOUR_ORG/dhi-node:24-alpine3.23
WORKDIR /app
COPY --from=build --chown=65532:0 /app/dist ./dist
COPY --from=build --chown=65532:0 /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
Deploy:
apiVersion: apps/v1
kind: Deployment
metadata:
name: node-app
spec:
replicas: 2
selector:
matchLabels:
app: node-app
template:
metadata:
labels:
app: node-app
spec:
containers:
- name: app
image: YOUR_ORG/dhi-node-app:latest
ports:
- containerPort: 3000
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
capabilities:
drop:
- ALL
imagePullSecrets:
- name: dhi-pull-secret
Handle arbitrary user IDs
OpenShift’s restricted-v2 SCC assigns a random UID to the container process.
This UID won’t exist in /etc/passwd inside the image, but the container will
still run — the process just won’t have a username associated with it.
This can cause issues with applications that:
- Look up the current user’s home directory or username
- Write to directories owned by a specific UID
- Check
/etc/passwdfor 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"]
[!NOTE]
For distroless runtime images (no shell), the passwd-injection pattern is not possible. Instead, use the
nonrootSCC (described in the following section) to run with the image’s built-in UID so the existing/etc/passwdentry matches the running process. Alternatively, OpenShift 4.x automatically injects the arbitrary UID into/etc/passwdin most cases, which resolves this for many applications.
Use the non-root SCC for fixed UIDs
If your application requires running as the specific UID defined in the image
(typically 65532 for DHI), you can use the nonroot SCC instead of the default
restricted-v2. The nonroot SCC uses the MustRunAsNonRoot strategy, which
allows any non-zero UID.
[!IMPORTANT]
For the
nonrootSCC to work, the image’sUSERdirective must specify a numeric UID (for example,65532), not a username string likenonroot. 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, setrunAsUserexplicitly in the pod spec.
Create a service account and grant it the nonroot SCC:
oc create serviceaccount dhi-nonroot
oc adm policy add-scc-to-user nonroot -z dhi-nonroot
Reference the service account in your deployment:
spec:
template:
spec:
serviceAccountName: dhi-nonroot
containers:
- name: app
image: YOUR_ORG/dhi-node:24-alpine3.23
securityContext:
runAsUser: 65532
runAsNonRoot: true
allowPrivilegeEscalation: false
seccompProfile:
type: RuntimeDefault
capabilities:
drop:
- ALL
Verify the SCC assignment after deployment:
oc get pod <pod-name> -o jsonpath='{.metadata.annotations.openshift\.io/scc}'
This should return nonroot.
When using the nonroot SCC with a fixed UID, the process runs as 65532
(matching the image’s file ownership), so the GID 0 adjustments are not strictly
required for paths already owned by 65532. However, applying chown <UID>:0 is
still recommended for portability across both restricted-v2 and nonroot SCCs.
Use DHI dev variants in OpenShift
DHI -dev variants include a shell, package manager, and development tools.
They run as root (UID 0) by default, which conflicts with OpenShift’s
restricted-v2 SCC. There are three approaches:
Option 1: Use dev variants only in build stages (recommended)
Use -dev variants only in Dockerfile build stages and never deploy them
directly to OpenShift:
FROM YOUR_ORG/dhi-node:24-alpine3.23-dev AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Set root group ownership for OpenShift compatibility
RUN chgrp -R 0 /app/dist /app/node_modules && \
chmod -R g=u /app/dist /app/node_modules
FROM YOUR_ORG/dhi-node:24-alpine3.23
WORKDIR /app
COPY --from=build --chown=65532:0 /app/dist ./dist
COPY --from=build --chown=65532:0 /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
The final runtime image is non-root and distroless, fully compatible with
restricted-v2.
Option 2: Grant the anyuid SCC for debugging
If you need to run a -dev variant directly in OpenShift for debugging, grant
the anyuid SCC to a dedicated service account:
oc create serviceaccount dhi-debug
oc adm policy add-scc-to-user anyuid -z dhi-debug
Then reference it in your pod:
apiVersion: v1
kind: Pod
metadata:
name: dhi-debug
spec:
serviceAccountName: dhi-debug
containers:
- name: debug
image: YOUR_ORG/dhi-node:24-alpine3.23-dev
command: ["sleep", "infinity"]
imagePullSecrets:
- name: dhi-pull-secret
[!IMPORTANT]
The
anyuidSCC allows running as any UID including root. Only use this for temporary debugging — never in production workloads.
Option 3: Use oc debug or ephemeral containers
For distroless runtime images with no shell, use OpenShift-native debugging
tools instead of docker debug (which only works with Docker Engine, not with
CRI-O on OpenShift).
Use oc debug to create a copy of a pod with a debug shell:
# Create a debug pod based on a deployment
oc debug deployment/nginx-dhi
# Override the image to use a -dev variant with a shell
oc debug deployment/nginx-dhi --image=YOUR_ORG/dhi-node:24-alpine3.23-dev
Use ephemeral containers (OpenShift 4.12+ / Kubernetes 1.25+):
kubectl debug -it <pod-name> --image=YOUR_ORG/dhi-node:24-alpine3.23-dev \
--target=app -- sh
This attaches a temporary debug container to a running pod without restarting it, sharing the pod’s process namespace.
[!NOTE]
docker debugis a Docker Desktop/CLI feature for local development. It is not available on OpenShift clusters, which use CRI-O as their container runtime.
Deploy DHI Helm charts on OpenShift
DHI provides pre-configured Helm charts for popular applications. When deploying these charts on OpenShift, you may need to adjust security context settings.
Inspect chart values first
Before installing, check what security context values the chart exposes:
helm registry login dhi.io
helm show values oci://dhi.io/<chart-name> --version <version> | grep -A 20 securityContext
The available value paths vary by chart, so always check values.yaml before
setting overrides.
Install with OpenShift overrides
The following example shows a typical installation pattern. Adjust the --set
paths based on what helm show values returns for your specific chart:
helm install my-release oci://dhi.io/<chart-name> \
--version <version> \
--set "imagePullSecrets[0].name=dhi-pull-secret" \
-f openshift-values.yaml
Create an openshift-values.yaml with security context overrides appropriate
for your chart:
# Example — adjust keys based on `helm show values` output
podSecurityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
[!NOTE]
DHI Helm chart value paths are not standardized across charts. For example, one chart may use
image.imagePullSecrets, while another usesglobal.imagePullSecrets. Always consult the specific chart’s documentation orvalues.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 withdocker 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.
[!WARNING]
Exposing the daemon on TCP without TLS makes your system vulnerable to remote code execution attacks. Turn off the setting when you are done testing.
Step 1: Scan without VEX
Sign in to the Docker Hardened Images registry:
$ docker login dhi.io
Then pull the image:
$ docker pull dhi.io/python:3.13
Then scan without VEX to see the raw CVE count. Docker Scout automatically applies VEX on Docker Hardened Images. To see the unfiltered CVE baseline, use Trivy or Grype.
{{< tabs >}} {{< tab name="Trivy" >}}
$ trivy image --scanners vuln dhi.io/python:3.13
If Trivy isn't installed, run it in a container:
$ docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln dhi.io/python:3.13
Example output:
Total: 30 (UNKNOWN: 0, LOW: 15, MEDIUM: 11, HIGH: 4, CRITICAL: 0)
{{< /tab >}} {{< tab name="Grype" >}}
$ grype dhi.io/python:3.13
If Grype isn't installed, run it in a container:
$ docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
anchore/grype:latest docker:dhi.io/python:3.13
Example output:
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY
libc6 2.41-12+deb13u2 deb CVE-2018-20796 Negligible
libc6 2.41-12+deb13u2 (won't fix) deb CVE-2026-4437 High
libc6 2.41-12+deb13u2 (won't fix) deb CVE-2026-5450 Critical
...
{{< /tab >}} {{< /tabs >}}
The output lists CVEs across libc6, libncursesw6, libsqlite3-0, libuuid1,
zlib1g, and others, all runtime dependencies that Python needs to function.
These packages are present by design.
A scan result like this doesn't mean every reported CVE requires patching. It means these CVEs have been reported against packages present in the image. Whether any of those CVEs are actually exploitable in this configuration is a separate question, and that's exactly what VEX answers.
Step 2: Fetch the VEX attestation
Export the VEX attestation to a local file:
$ docker scout vex get registry://dhi.io/python:3.13 --output python-vex.json
The registry:// prefix tells Scout to fetch the attestation from the registry
rather than the local image store. Because you pulled the image in Step 1, it
already exists locally, and without this prefix Scout would find no attestation
there.
This fetches a signed OpenVEX document from registry.scout.docker.com,
Docker's supply chain metadata registry for all Docker Hardened Images. The
document records Docker's exploitability assessment for every CVE found in the
image's SBOM.
[!NOTE]
Docker Scout fetches this file automatically when scanning. You only need to download it explicitly for scanners that don't natively integrate it, or to run the
jqqueries 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.
[!IMPORTANT]
PURL matching is strict. Scanners must match VEX statements to packages using the full PURL string, including the
os_name,os_version, andos_distroqualifiers. 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.
[!TIP]
If you're new to Docker, start with the Docker basics guide to get familiar with key concepts like images, containers, and Dockerfiles.
Overview
This guide walks you through containerizing a Django application with Docker. By the end, you will:
- 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
developmentstage 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 ."[!NOTE]
The previous command uses Mac/Linux shell syntax. On Windows, adjust the path: PowerShell uses
${PWD}, Command Prompt uses%cd%, Git Bash requiresMSYS_NO_PATHCONV=1with$(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
.dockerignorefile to exclude local artifacts from the build context:.venv/ __pycache__/ *.pyc .git/ -
Create a
Dockerfilewith 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.yamlfile:services: web: build: . ports: - "8000:8000"
Run the application
From the django-docker directory, run:
$ docker compose up --build
Open a browser and navigate to http://localhost:8000. You should see the Django welcome page.
Press ctrl+c to stop the application.
Set up a development environment
The production setup uses Gunicorn and requires a full image rebuild to pick up
code changes. For development, you can add a development stage to your
Dockerfile that uses Django's built-in server, and configure Compose Watch to
automatically sync code changes into the running container without a rebuild.
Update the Dockerfile
Replace your Dockerfile with a multi-stage version that adds a development
stage alongside production:
# syntax=docker/dockerfile:1
# Build stage: the -dev image includes tools needed to install packages.
FROM dhi.io/python:3.14-alpine3.23-dev AS builder
# Prevent Python from writing .pyc files to disk.
ENV PYTHONDONTWRITEBYTECODE=1
# Prevent Python from buffering stdout/stderr so logs appear immediately.
ENV PYTHONUNBUFFERED=1
RUN pip install --quiet --root-user-action=ignore uv
# Use copy mode since the cache and build filesystem are on different volumes.
ENV UV_LINK_MODE=copy
WORKDIR /app
# Install dependencies into a virtual environment using cache and bind mounts
# so neither uv nor the lock files need to be copied into the image.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project
# The development stage inherits the -dev image and virtual environment from
# the builder. Django's built-in server reloads when Compose Watch syncs files.
FROM builder AS development
ENV PATH="/app/.venv/bin:$PATH"
COPY . .
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# The production stage uses the minimal runtime image, which has no shell,
# no package manager, and already runs as the nonroot user.
FROM dhi.io/python:3.14-alpine3.23 AS production
# Prevent Python from buffering stdout/stderr so logs appear immediately.
ENV PYTHONUNBUFFERED=1
# Activate the virtual environment copied from the build stage.
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
# Copy only the pre-built virtual environment and application source code.
COPY --from=builder /app/.venv /app/.venv
COPY . .
EXPOSE 8000
# Run Gunicorn as the production WSGI server.
CMD ["gunicorn", "myapp.wsgi:application", "--bind", "0.0.0.0:8000"]
Update the Compose file
Replace your compose.yaml with the following. It targets the development
stage, adds a PostgreSQL database, and configures Compose Watch:
services:
web:
build:
context: .
# Build the development stage from the multi-stage Dockerfile.
target: development
ports:
- "8000:8000"
environment:
# Enable Django's verbose debug error pages (the dev server always auto-reloads).
- DEBUG=1
# Database connection settings passed to Django via environment variables.
- POSTGRES_DB=myapp
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
# Wait for the database to pass its healthcheck before starting the web service.
depends_on:
db:
condition: service_healthy
develop:
watch:
# Sync source file changes directly into the container so Django's
# dev server can reload them without a full image rebuild.
- action: sync
path: .
target: /app
ignore:
- __pycache__/
- "*.pyc"
- .git/
- .venv/
# Rebuild the image when dependencies change.
- action: rebuild
path: pyproject.toml
- action: rebuild
path: uv.lock
db:
image: dhi.io/postgres:18
restart: always
volumes:
# Persist database data across container restarts.
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
# Expose the port only to other services on the Compose network,
# not to the host machine.
expose:
- 5432
# Only report healthy once PostgreSQL is ready to accept connections,
# so the web service doesn't start before the database is available.
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
The sync action pushes file changes directly into the running container so
Django's dev server reloads them automatically. A change to pyproject.toml or
uv.lock triggers a full image rebuild instead.
[!NOTE]
To learn more about Compose Watch, see Use Compose Watch.
Add the PostgreSQL driver
Add the psycopg adapter to your project:
{{< tabs >}} {{< tab name="Local (uv)" >}}
$ uv add 'psycopg[binary]'
{{< /tab >}} {{< tab name="Container (DHI)" >}}
$ docker run --rm -v $PWD:$PWD -w $PWD \
-e UV_LINK_MODE=copy \
dhi.io/python:3.14-alpine3.23-dev \
sh -c "pip install --quiet --root-user-action=ignore uv && uv add 'psycopg[binary]'"
{{< /tab >}} {{< /tabs >}}
Then update myapp/settings.py to read DEBUG and DATABASES from environment
variables:
import os
DEBUG = os.environ.get('DEBUG', '0') == '1'
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "myapp"),
"USER": os.environ.get("POSTGRES_USER", "postgres"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "password"),
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
}
}
Run with Compose Watch
Start the development stack:
$ docker compose watch
Open a browser and navigate to http://localhost:8000.
Try editing a file, for example add a view to myapp/views.py. Compose Watch
syncs the change into the container and Django's dev server reloads
automatically. If you update pyproject.toml or uv.lock, Compose Watch
triggers a full image rebuild.
Press ctrl+c to stop.
Summary
In this guide, you:
- 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
developmentstage to theDockerfileand configured Compose Watch for fast iterative development with a PostgreSQL database.
- 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).
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored 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"]
[!NOTE]
DHI runtime images already run as a non-root user (
nonroot, UID 65532), so there's no need to create a user or specifyUSERin your Dockerfile. This reduces the attack surface and simplifies your configuration.
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
target: final
ports:
- 8080:8080
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
# depends_on:
# db:
# condition: service_healthy
# db:
# image: postgres
# restart: always
# user: postgres
# secrets:
# - db-password
# volumes:
# - db-data:/var/lib/postgresql/data
# environment:
# - POSTGRES_DB=example
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
# expose:
# - 5432
# healthcheck:
# test: [ "CMD", "pg_isready" ]
# interval: 10s
# timeout: 5s
# retries: 5
# volumes:
# db-data:
# secrets:
# db-password:
# file: db/password.txt
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
{{< /tab >}} {{< tab name="Using the official .NET 10 image" >}}
Create the following files in your docker-dotnet-sample directory.
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY --from=build /app .
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
USER appuser
ENTRYPOINT ["dotnet", "myWebApp.dll"]
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
target: final
ports:
- 8080:8080
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
# depends_on:
# db:
# condition: service_healthy
# db:
# image: postgres
# restart: always
# user: postgres
# secrets:
# - db-password
# volumes:
# - db-data:/var/lib/postgresql/data
# environment:
# - POSTGRES_DB=example
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
# expose:
# - 5432
# healthcheck:
# test: [ "CMD", "pg_isready" ]
# interval: 10s
# timeout: 5s
# retries: 5
# volumes:
# db-data:
# secrets:
# db-password:
# file: db/password.txt
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
{{< /tab >}} {{< /tabs >}}
You should now have the following contents in your docker-dotnet-sample
directory.
├── docker-dotnet-sample/
│ ├── .git/
│ ├── src/
│ ├── .dockerignore
│ ├── compose.yaml
│ ├── Dockerfile
│ └── README.md
To learn more about the files, see the following:
Run the application
Inside the docker-dotnet-sample directory, run the following command in a
terminal.
$ docker compose up --build
Open a browser and view the application at http://localhost:8080. You should see a simple web application.
In the terminal, press ctrl+c to stop the application.
Run the application in the background
You can run the application detached from the terminal by adding the -d
option. Inside the docker-dotnet-sample directory, run the following command
in a terminal.
$ docker compose up --build -d
Open a browser and view the application at http://localhost:8080. You should see a simple web application.
In the terminal, run the following command to stop the application.
$ docker compose down
For more information about Compose commands, see the Compose CLI reference.
Use containers for .NET development
Prerequisites
Complete Containerize a .NET application.
Overview
In this section, you'll learn how to set up a development environment for your containerized application. This includes:
- 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
[!NOTE]
To learn more about the instructions in the Compose file, see Compose file reference.
Before you run the application using Compose, notice that this Compose file uses
secrets and specifies a password.txt file to hold the database's password.
You must create this file as it's not included in the source repository.
In the docker-dotnet-sample directory, create a new directory named db and
inside that directory create a file named password.txt. Open password.txt in an IDE or text editor and add the following password. The password must be on a single line, with no additional lines in the file.
example
Save and close the password.txt file.
You should now have the following in your docker-dotnet-sample directory.
├── docker-dotnet-sample/
│ ├── .git/
│ ├── db/
│ │ └── password.txt
│ ├── src/
│ ├── tests/
│ ├── .dockerignore
│ ├── .gitignore
│ ├── compose.yaml
│ ├── Dockerfile
│ └── README.md
Run the following command to start your application.
$ docker compose up --build
Open a browser and view the application at http://localhost:8080. You should see a simple web application with the text Student name is.
The application doesn't display a name because the database is empty. For this application, you need to access the database and then add records.
Add records to the database
For the sample application, you must access the database directly to create sample records.
You can run commands inside the database container using the docker exec
command. Before running that command, you must get the ID of the database
container. Open a new terminal window and run the following command to list all
your running containers.
$ docker container ls
You should see output like the following.
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cb36e310aa7e docker-dotnet-sample-server "dotnet myWebApp.dll" About a minute ago Up About a minute 0.0.0.0:8080->8080/tcp docker-dotnet-sample-server-1
39fdcf0aff7b postgres:18 "docker-entrypoint.s…" About a minute ago Up About a minute (healthy) 5432/tcp docker-dotnet-sample-db-1
In the previous example, the container ID is 39fdcf0aff7b. Run the following command to connect to the postgres database in the container. Replace the container ID with your own container ID.
$ docker exec -it 39fdcf0aff7b psql -d example -U postgres
And finally, insert a record into the database.
example=# INSERT INTO "Students" ("ID", "LastName", "FirstMidName", "EnrollmentDate") VALUES (DEFAULT, 'Whale', 'Moby', '2013-03-20');
You should see output like the following.
INSERT 0 1
Close the database connection and exit the container shell by running exit.
example=# exit
Verify that data persists in the database
Open a browser and view the application at http://localhost:8080. You should see a simple web application with the text Student name is Moby Whale.
Press ctrl+c in the terminal to stop your application.
In the terminal, run docker compose rm to remove your containers and then run docker compose up to run your application again.
$ docker compose rm
$ docker compose up --build
Refresh http://localhost:8080 in your browser and verify that the student name persisted, even after the containers were removed and ran again.
Press ctrl+c in the terminal to stop your application.
Automatically update services
Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.
Open your compose.yaml file in an IDE or text editor and then add the Compose Watch instructions. The following is the updated compose.yaml file.
services:
server:
build:
context: .
target: final
ports:
- 8080:8080
depends_on:
db:
condition: service_healthy
develop:
watch:
- action: rebuild
path: .
db:
image: postgres:18
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- 5432
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
Run the following command to run your application with Compose Watch.
$ docker compose watch
Open a browser and verify that the application is running at http://localhost:8080.
Any changes to the application's source files on your local machine will now be immediately reflected in the running container.
Open docker-dotnet-sample/src/Pages/Index.cshtml in an IDE or text editor and update the student name text on line 13 from Student name is to Student name:.
- <p>Student name is @Model.StudentName</p>
+ <p>Student name: @Model.StudentName</p>
Save the changes to Index.cshtml and then wait a few seconds for the application to rebuild. Refresh http://localhost:8080 in your browser and verify that the updated text appears.
Press ctrl+c in the terminal to stop your application.
Create a development container
At this point, when you run your containerized application, it's using the .NET runtime image. While this small image is good for production, it lacks the SDK tools and dependencies you may need when developing. Also, during development, you may not need to run dotnet publish. You can use multi-stage builds to build stages for both development and production in the same Dockerfile. For more details, see Multi-stage builds.
Add a new development stage to your Dockerfile and update your compose.yaml file to use this stage for local development.
The following is the updated Dockerfile.
{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM dhi.io/dotnet:10-sdk AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app
FROM dhi.io/dotnet:10-sdk AS development
COPY . /source
WORKDIR /source/src
CMD dotnet run --no-launch-profile
FROM dhi.io/aspnetcore:10 AS final
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "myWebApp.dll"]
{{< /tab >}} {{< tab name="Using the official .NET 10 image" >}}
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS development
COPY . /source
WORKDIR /source/src
CMD dotnet run --no-launch-profile
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY --from=build /app .
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
USER appuser
ENTRYPOINT ["dotnet", "myWebApp.dll"]
{{< /tab >}} {{< /tabs >}}
The following is the updated compose.yaml file.
services:
server:
build:
context: .
target: development
ports:
- 8080:8080
depends_on:
db:
condition: service_healthy
develop:
watch:
- action: rebuild
path: .
environment:
- ASPNETCORE_ENVIRONMENT=Development
db:
image: postgres:18
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- 5432
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
Your containerized application will now use the SDK image (either dhi.io/dotnet:10-sdk for DHI or mcr.microsoft.com/dotnet/sdk:10.0-alpine for official images), which includes development tools like dotnet test. Continue to the next section to learn how you can run dotnet test.
Run .NET tests in a container
Prerequisites
Complete all the previous sections of this guide, starting with Containerize a .NET application.
Overview
Testing is an essential part of modern software development. Testing can mean a lot of things to different development teams. There are unit tests, integration tests and end-to-end testing. In this guide you take a look at running your unit tests in Docker when developing and when building.
Run tests when developing locally
The sample application already has an xUnit test inside the tests directory. When developing locally, you can use Compose to run your tests.
Run the following command in the docker-dotnet-sample directory to run the tests inside a container.
$ docker compose run --build --rm server dotnet test /source/tests
You should see output that contains the following.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: < 1 ms - /source/tests/bin/Debug/net10.0/tests.dll (net10.0)
To learn more about the command, see docker compose run.
Run tests when building
To run your tests when building, you need to update your Dockerfile. You can create a new test stage that runs the tests, or run the tests in the existing build stage. For this guide, update the Dockerfile to run the tests in the build stage.
The following is the updated Dockerfile.
{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM dhi.io/dotnet:10-sdk AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app
RUN dotnet test /source/tests
FROM dhi.io/dotnet:10-sdk AS development
COPY . /source
WORKDIR /source/src
CMD dotnet run --no-launch-profile
FROM dhi.io/aspnetcore:10 AS final
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "myWebApp.dll"]
{{< /tab >}} {{< tab name="Using the official .NET 10 image" >}}
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
ARG TARGETARCH
COPY . /source
WORKDIR /source/src
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app
RUN dotnet test /source/tests
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS development
COPY . /source
WORKDIR /source/src
CMD dotnet run --no-launch-profile
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
COPY --from=build /app .
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
USER appuser
ENTRYPOINT ["dotnet", "myWebApp.dll"]
{{< /tab >}} {{< /tabs >}}
Run the following command to build an image using the build stage as the target and view the test results. Include --progress=plain to view the build output, --no-cache to ensure the tests always run, and --target build to target the build stage.
$ docker build -t dotnet-docker-image-test --progress=plain --no-cache --target build .
You should see output containing the following.
#11 [build 5/5] RUN dotnet test /source/tests
#11 1.564 Determining projects to restore...
#11 3.421 Restored /source/src/myWebApp.csproj (in 1.02 sec).
#11 19.42 Restored /source/tests/tests.csproj (in 17.05 sec).
#11 27.91 myWebApp -> /source/src/bin/Debug/net10.0/myWebApp.dll
#11 28.47 tests -> /source/tests/bin/Debug/net10.0/tests.dll
#11 28.49 Test run for /source/tests/bin/Debug/net10.0/tests.dll (.NETCoreApp,Version=v10.0)
#11 28.67 Microsoft (R) Test Execution Command Line Tool Version 17.3.3 (x64)
#11 28.67 Copyright (c) Microsoft Corporation. All rights reserved.
#11 28.68
#11 28.97 Starting test execution, please wait...
#11 29.03 A total of 1 test files matched the specified pattern.
#11 32.07
#11 32.08 Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: < 1 ms - /source/tests/bin/Debug/net10.0/tests.dll (net10.0)
#11 DONE 32.2s
Summary
In this section, you learned how to run tests when developing locally using Compose and how to run tests when building your image.
Give sandboxed agents live web access with Firecrawl
Agents in a Docker Sandbox run under a network policy that usually blocks most of the web. That keeps the agent contained, but it also means the agent can't look up documentation or read a page you point it at. Firecrawl is a web data API that searches the web, scrapes pages to clean Markdown, and crawls whole sites. The Firecrawl kit gives a sandboxed agent that capability through a single API host, without opening the rest of the web.
In this guide, you'll learn how to:
- 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.
[!NOTE] Built-in shortcuts such as
sbx run claudeuse v2 kits, and v2 agents only accept v2 mixins. Firecrawl also publishes a v2 form of the kit for those agents. See the kit repository for v2 usage.
Step 3: Check the kit
Run these checks from the shell in the firecrawl-shell sandbox. Each one
covers a separate part of the kit.
Check that the SDK is installed at the pinned version, in the agent user's site-packages:
$ python3 -c "import firecrawl, importlib.metadata as m; print(m.version('firecrawl-py'), firecrawl.__file__)"
4.44.0 /home/agent/.local/lib/python3.14/site-packages/firecrawl/__init__.py
Check that the key in the sandbox is the proxy placeholder, not your real key:
$ echo "$FIRECRAWL_API_KEY"
proxy-managed
Then scrape a page to test the whole path: the SDK, the network rule, and credential injection. This request uses Firecrawl credits:
$ python3 - <<'EOF'
from firecrawl import Firecrawl
fc = Firecrawl()
doc = fc.scrape("https://docs.docker.com/ai/sandboxes/", formats=["markdown"])
print(doc.markdown[:300])
EOF
The output is the start of the page as Markdown. If the request fails with
Unauthorized, the proxy didn't add your key. Check that you stored the
secret under firecrawl and approved the kit's request. A
PaymentRequiredError means the request authenticated and the account is out
of credits.
Step 4: Use Firecrawl from the agent
The kit's agent instructions point the agent at the SDK, so a coding agent can use Firecrawl when a task needs current information or the contents of a URL. The main calls are:
from firecrawl import Firecrawl
fc = Firecrawl() # The proxy adds the API key
fc.scrape("https://example.com", formats=["markdown"]) # One page as Markdown
fc.search("docker sandboxes kits", limit=5) # Web search results
fc.crawl("https://docs.example.com", limit=20) # Pages across a site
Each crawled page uses credits, so keep limit low and prefer a search
followed by a targeted scrape when the agent needs a single fact. For the full
API, including structured extraction with a schema, see the
Firecrawl Python SDK documentation.
How the kit interacts with network policy
Without organization governance, your default network policy and kit-defined rules determine which domains the sandbox can access. Under organization governance, organization policy controls which domains can be allowed; local and kit-defined allow rules are inactive, while deny rules can still restrict access. See Network policy precedence.
The Firecrawl kit requests access to PyPI during installation and
api.firecrawl.dev at runtime. Its credential declaration tells the proxy to
inject your Firecrawl key only into requests to api.firecrawl.dev.
Firecrawl fetches target pages on its servers. Scraping a URL through Firecrawl doesn't require the sandbox to have direct access to that URL.
To see which hosts the sandbox contacted, run this on the host:
$ sbx policy log firecrawl-shell
ALLOWED REQUESTS
SANDBOX TYPE HOST PROXY RULE REASON LAST SEEN COUNT
firecrawl-shell network api.firecrawl.dev forward domain-allowed 10:16:02 15-Oct 1
firecrawl-shell network files.pythonhosted.org forward domain-allowed 10:15:41 15-Oct 6
firecrawl-shell network pypi.org forward domain-allowed 10:15:40 15-Oct 4
The scrape of docs.docker.com doesn't appear in this log because Firecrawl
fetches that page on its servers. The sandbox sends its request to
api.firecrawl.dev.
Clean up
Sandboxes persist after you exit. To remove the sandbox from this guide:
$ sbx rm firecrawl-shell
Files in your workspace are unaffected. To remove the stored key, run
sbx secret rm firecrawl.
Learn more
Generate Docker Compose Files with Claude Code and Docker MCP Toolkit
This guide introduces how to use Claude Code together with Docker MCP Toolkit so Claude can search Docker Hub in real time and generate a complete docker-compose.yaml from natural language.
Instead of manually writing YAML or looking for image tags, you describe your stack once — Claude uses the Model Context Protocol (MCP) to query Docker Hub and build a production-ready Compose file.
In this guide, you’ll learn how to:
- 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 onlocalhost: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
$ 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.
[!Note] If you don't want to run the application locally, and want to run it in a Docker container, skip to next page where you create a Dockerfile and containerize the application.
Summary
In this section, you learned how to create a Golang app to register metrics with Prometheus. By implementing middleware functions, you were able to increment the counters based on the request path and status codes.
Next steps
In the next section, you'll learn how to containerize your application.
Containerize a Golang application
Containerization helps you bundle the application and its dependencies into a single package called a container. This package can run on any platform without worrying about the environment. In this section, you will learn how to containerize a Golang application using Docker.
To containerize a Golang application, you first need to create a Dockerfile. The Dockerfile contains instructions to build and run the application in a container. Also, when creating a Dockerfile, you can follow different sets of best practices to optimize the image size and make it more secure.
Creating a Dockerfile
Create a new file named Dockerfile in the root directory of your Golang application. The Dockerfile contains instructions to build and run the application in a container.
The following is a Dockerfile for a Golang application. You will also find this file in the go-prometheus-monitoring directory.
# Use the official Golang image as the base
FROM golang:1.24-alpine AS builder
# Set environment variables
ENV CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
# Set working directory inside the container
WORKDIR /build
# Copy go.mod and go.sum files for dependency installation
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy the entire application source
COPY . .
# Build the Go binary
RUN go build -o /app .
# Final lightweight stage
FROM alpine:3.21 AS final
# Copy the compiled binary from the builder stage
COPY --from=builder /app /bin/app
# Expose the application's port
EXPOSE 8000
# Run the application
CMD ["bin/app"]
Understanding the Dockerfile
The Dockerfile consists of two stages:
-
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.modandgo.sumfiles for dependency installation, downloads the dependencies, copies the entire application source, and builds the Go binary.You use the
golang:1.24-alpineimage as the base image for the build stage. TheCGO_ENABLED=0environment variable disables CGO, which is useful for building static binaries. You also set theGOOSandGOARCHenvironment variables tolinuxandamd64, 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.21image 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 theEXPOSEinstruction and run the application using theCMDinstruction.Apart from the multi-stage build, the Dockerfile also follows best practices such as using the official images, setting the working directory, and copying only the necessary files to the final image. You can further optimize the Dockerfile by other best practices.
Build the Docker image and run the application
One you have the Dockerfile, you can build the Docker image and run the application in a container.
To build the Docker image, run the following command in the terminal:
$ docker build -t go-api:latest .
After building the image, you can run the application in a container using the following command:
$ docker run -p 8000:8000 go-api:latest
The application will start running inside the container, and you can access it at http://localhost:8000. You can also check the running containers using the docker ps command.
$ docker ps
Summary
In this section, you learned how to containerize a Golang application using a Dockerfile. You created a multi-stage Dockerfile to build and run the application in a container. You also learned about best practices to optimize the Docker image size and make it more secure.
Next steps
In the next section, you will learn how to use Docker Compose to connect and run multiple services together to monitor a Golang application with Prometheus and Grafana.
Connecting services with Docker Compose
Now that you have containerized the Golang application, you will use Docker Compose to connect your services together. You will connect the Golang application, Prometheus, and Grafana services together to monitor the Golang application with Prometheus and Grafana.
Creating a Docker Compose file
Create a new file named compose.yml in the root directory of your Golang application. The Docker Compose file contains instructions to run multiple services and connect them together.
Here is a Docker Compose file for a project that uses Golang, Prometheus, and Grafana. You will also find this file in the go-prometheus-monitoring directory.
services:
api:
container_name: go-api
build:
context: .
dockerfile: Dockerfile
image: go-api:latest
ports:
- 8000:8000
networks:
- go-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 5
develop:
watch:
- path: .
action: rebuild
prometheus:
container_name: prometheus
image: prom/prometheus:v2.55.0
volumes:
- ./Docker/prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- 9090:9090
networks:
- go-network
grafana:
container_name: grafana
image: grafana/grafana:11.3.0
volumes:
- ./Docker/grafana.yml:/etc/grafana/provisioning/datasources/datasource.yaml
- grafana-data:/var/lib/grafana
ports:
- 3000:3000
networks:
- go-network
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=password
volumes:
grafana-data:
networks:
go-network:
driver: bridge
Understanding the Docker Compose file
The Docker Compose file consists of three services:
-
Golang application service: This service builds the Golang application using the Dockerfile and runs it in a container. It exposes the application's port
8000and connects to thego-networknetwork. It also defines a health check to monitor the application's health. You have also usedhealthcheckto 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 thecurlcommand to check the/healthendpoint of the application. Apart from the health check, you have also added adevelopsection 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 port9090and connects to thego-networknetwork. You have also mounted theprometheus.ymlfile from theDockerdirectory which is present in the root directory of your project. Theprometheus.ymlfile 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.ymlfile, you have defined a job namedmyappto scrape the metrics from the Golang application. Thetargetsfield specifies the target to scrape the metrics from. In this case, the target is the Golang application running on port8000. Theapiis 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 port3000and connects to thego-networknetwork. You have also mounted thegrafana.ymlfile from theDockerdirectory which is present in the root directory of your project. Thegrafana.ymlfile 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: trueIn the
grafana.ymlfile, you have defined a Prometheus data source namedPrometheus (Main). Thetypefield specifies the type of the data source, which isprometheus. Theurlfield specifies the URL of the Prometheus server to fetch the metrics from. In this case, the URL ishttp://prometheus:9090.prometheusis the service name of the Prometheus server in the Docker Compose file. TheisDefaultfield 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.
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
Dockerfilewhich 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
gitclient, 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.
[!NOTE]
If you are curious about creating your own base images, you can check out the following section of this guide: creating base images. Note, however, that this isn't necessary to continue with your task at hand.
Now that you have defined the base image for your upcoming container image, you can begin building on top of it.
To make things easier when running the rest of your commands, create a directory
inside the image that you're building. This also instructs Docker to use this
directory as the default destination for all subsequent commands. This way you
don't have to type out full file paths in the Dockerfile, the relative paths
will be based on this directory.
WORKDIR /app
Usually the very first thing you do once you’ve downloaded a project written in Go is to install the modules necessary to compile it. Note, that the base image has the toolchain already, but your source code isn't in it yet.
So before you can run go mod download inside your image, you need to get your
go.mod and go.sum files copied into it. Use the COPY command to do this.
In its simplest form, the COPY command takes two parameters. The first
parameter tells Docker what files you want to copy into the image. The last
parameter tells Docker where you want that file to be copied to.
Copy the go.mod and go.sum file into your project directory /app which,
owing to your use of WORKDIR, is the current directory (./) inside the
image. Unlike some modern shells that appear to be indifferent to the use of
trailing slash (/), and can figure out what the user meant (most of the time),
Docker's COPY command is quite sensitive in its interpretation of the trailing
slash.
COPY go.mod go.sum ./
[!NOTE]
If you'd like to familiarize yourself with the trailing slash treatment by the
COPYcommand, 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
quitto 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-pingto 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
POSTrequest to/sendcontaining 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.
[!NOTE]
Since you're running your CockroachDB cluster in insecure mode, the value for the password can be anything.
In production, don't run in insecure mode.
$ docker run -it --rm -d \
--network mynet \
--name rest-server \
-p 80:8080 \
-e PGUSER=totoro \
-e PGPASSWORD=myfriend \
-e PGHOST=db \
-e PGPORT=26257 \
-e PGDATABASE=mydb \
docker-gs-ping-roach
There are a few points to note about this command.
-
You map container port
8080to host port80this time. Thus, forGETrequests you can get away with literallycurl 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
0for 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 dbwhen 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 variablePGUSERshall 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 oftotoro.PGPASSWORD=${PGPASSWORD:?database password not set}means that if the environment variablePGPASSWORDisn'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.envfile, which is local to your machine. It is always a good idea to add.envto.gitignoreto 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.
[!NOTE]
Docker Compose is a useful tool, but it has its own quirks. For example, no rebuild is triggered on the update to the source code unless the
--buildflag is provided. It is a very common pitfall to edit one's source code, and forget to use the--buildflag when runningdocker 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
deployandreplicas.
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.
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_rulesto see which alert is firing. - Use
search_dashboardsto find the relevant application dashboard. - Use
get_panel_imageon a key panel to see the performance spike visually. - Use
query_loki_logsto 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_incidentto 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.
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored to your application.
Create a file named Dockerfile with the following contents.
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
################################################################################
# Create a stage for resolving and downloading dependencies.
FROM eclipse-temurin:21-jdk-jammy as deps
WORKDIR /build
# Copy the mvnw wrapper with executable permissions.
COPY --chmod=0755 mvnw mvnw
COPY .mvn/ .mvn/
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.m2 so that subsequent builds don't have to
# re-download packages.
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
--mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline -DskipTests
################################################################################
# Create a stage for building the application based on the stage with downloaded dependencies.
# This Dockerfile is optimized for Java applications that output an uber jar, which includes
# all the dependencies needed to run your app inside a JVM. If your app doesn't output an uber
# jar and instead relies on an application server like Apache Tomcat, you'll need to update this
# stage with the correct filename of your package and update the base image of the "final" stage
# use the relevant app server, e.g., using tomcat (https://hub.docker.com/_/tomcat/) as a base image.
FROM deps as package
WORKDIR /build
COPY ./src src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
--mount=type=cache,target=/root/.m2 \
./mvnw package -DskipTests && \
mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar
################################################################################
# Create a stage for extracting the application into separate layers.
# Take advantage of Spring Boot's layer tools and Docker's caching by extracting
# the packaged application into separate layers that can be copied into the final stage.
# See Spring's docs for reference:
# https://docs.spring.io/spring-boot/docs/current/reference/html/container-images.html
FROM package as extract
WORKDIR /build
RUN java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted
################################################################################
# Create a new stage for running the application that contains the minimal
# runtime dependencies for the application. This often uses a different base
# image from the install or build stage where the necessary files are copied
# from the install stage.
#
# The example below uses eclipse-turmin's JRE image as the foundation for running the app.
# By specifying the "17-jre-jammy" tag, it will also use whatever happens to be the
# most recent version of that tag when you build your Dockerfile.
# If reproducibility is important, consider using a specific digest SHA, like
# eclipse-temurin@sha256:99cede493dfd88720b610eb8077c8688d3cca50003d76d1d539b0efc8cca72b4.
FROM eclipse-temurin:21-jre-jammy AS final
# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
USER appuser
# Copy the executable from the "package" stage.
COPY --from=extract build/target/extracted/dependencies/ ./
COPY --from=extract build/target/extracted/spring-boot-loader/ ./
COPY --from=extract build/target/extracted/snapshot-dependencies/ ./
COPY --from=extract build/target/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT [ "java", "org.springframework.boot.loader.launch.JarLauncher" ]
[!NOTE] The sample repository includes a
docker-compose.ymlfile. The following instructions use the preferredcompose.yamlfilename — both are supported by Docker Compose.
Create a file named compose.yaml with the following contents.
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
ports:
- 8080:8080
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
# depends_on:
# db:
# condition: service_healthy
# db:
# image: postgres:18
# restart: always
# user: postgres
# secrets:
# - db-password
# volumes:
# - db-data:/var/lib/postgresql
# environment:
# - POSTGRES_DB=example
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
# expose:
# - 5432
# healthcheck:
# test: [ "CMD", "pg_isready" ]
# interval: 10s
# timeout: 5s
# retries: 5
# volumes:
# db-data:
# secrets:
# db-password:
# file: db/password.txt
Create a file named .dockerignore with the following contents.
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.y*ml
**/target
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/vendor
LICENSE
README.md
You should now have the following three files in your spring-petclinic
directory.
Run the application
Inside the spring-petclinic directory, run the following command in a
terminal.
$ docker compose up --build
The first time you build and run the app, Docker downloads dependencies and builds the app. It may take several minutes depending on your network connection.
Open a browser and view the application at http://localhost:8080. You should see a simple app for a pet clinic.
In the terminal, press ctrl+c to stop the application.
Run the application in the background
You can run the application detached from the terminal by adding the -d
option. Inside the spring-petclinic directory, run the following command
in a terminal.
$ docker compose up --build -d
Open a browser and view the application at http://localhost:8080. You should see a simple app for a pet clinic.
In the terminal, run the following command to stop the application.
$ docker compose down
For more information about Compose commands, see the Compose CLI reference.
Use containers for Java development
Prerequisites
Work through the steps to containerize your application in Containerize your app.
Overview
In this section, you’ll walk through setting up a local development environment for the application you containerized in the previous section. This includes:
- 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
secretselement as well as the element inside thedbservice. This example uses the environment variable for the password rather than secrets. - Remove the
userelement from thedbservice. 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
dbservice and specify the user. By default, the healthcheck uses the root user instead of thepetclinicuser you defined. - Add the database URL as an environment variable in the
serverservice. This overrides the default value defined inspring-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
workdirectory 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.
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 tagcommand. ReplaceYOUR-USER-NAMEwith your Docker ID.$ docker tag my-jupyter-image YOUR-USER-NAME/my-jupyter-image -
Run the following
docker pushcommand to push the image to Docker Hub. ReplaceYOUR-USER-NAMEwith 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 runto run your image with the imported volume. ReplaceYOUR-USER-NAMEwith your Docker ID.
$ docker run --rm -p 8889:8888 -v jupyter-data-2:/home/jovyan/work YOUR-USER-NAME/my-jupyter-image start-notebook.py --NotebookApp.token='my-token'
Summary
In this guide, you learned how to leverage Docker and JupyterLab to create reproducible data science environments, facilitating the development and sharing of data science projects. This included, running a personal JupyterLab server, customizing the environment with necessary tools and packages, and sharing notebooks and environments with other data scientists.
Developing event-driven applications with Kafka and Docker
With the rise of microservices, event-driven architectures have become increasingly popular. Apache Kafka, a distributed event streaming platform, is often at the heart of these architectures. Unfortunately, setting up and deploying your own Kafka instance for development is often tricky. Fortunately, Docker and containers make this much easier.
In this guide, you will learn how to:
- 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.
[!TIP]
This guide will be using the apache/kafka image, as it includes many helpful scripts to manage and work with Kafka. However, you may want to use the apache/kafka-native image, as it starts more quickly and requires fewer resources.
Starting Kafka
Start a basic Kafka cluster by doing the following steps. This example will launch a cluster, exposing port 9092 onto the host to let a native-running application to connect to it.
-
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/bindirectory. 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 :9092Doing 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 demoAfter running, you can enter a message per line. For example, enter a few messages, one per line. A few examples might be:
First messageAnd
Second messagePress
enterto 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-beginningYou should then see your messages in the output:
First message Second messageIf 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 theNODE_ENVenvironment variable todevelopmentand usenodemonto 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 demoAnd then send a message to the cluster:
Test messageRemember to press
ctrl+cwhen 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+cin 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 upAfter 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=maxand 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
.dockerignorefile 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.yamlfile and manage it with Compose commands - Control service startup order using health checks and
depends_onconditions - 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
includedirective - Use
config,logs, andexecto 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
Dockerfileto package an application, using layer caching for fast rebuilds - Build a custom image with
docker buildand 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.yamlthat 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.
[!NOTE] To experiment with a ready-to-run configuration, download the Laravel Docker Examples repository. It contains pre-configured setups for both development and production.
Project structure
my-laravel-app/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── docker/
│ ├── common/
│ │ └── php-fpm/
│ │ ├── Dockerfile
│ │ └── conf.d/
│ │ └── 20-status-path.conf
│ ├── development/
│ ├── production/
│ │ ├── php-fpm/
│ │ │ └── entrypoint.sh
│ │ └── nginx
│ │ ├── Dockerfile
│ │ └── nginx.conf
├── compose.dev.yaml
├── compose.prod.yaml
├── .dockerignore
├── .env
├── vendor/
├── ...
This layout represents a typical Laravel project, with Docker configurations stored in a unified docker directory. You’ll find two Compose files — compose.dev.yaml (for development) and compose.prod.yaml (for production) — to keep your environments separate and manageable.
Create a Dockerfile for PHP-FPM (production)
For production, the php-fpm Dockerfile creates an optimized image with only the PHP extensions and libraries your application needs. As demonstrated in the GitHub example, a single Dockerfile with multi-stage builds maintains consistency and reduces duplication between development and production. The following snippet shows only the production-related stages:
# Stage 1: Build environment and Composer dependencies
FROM php:8.5-fpm AS builder
# Install system dependencies and PHP extensions for Laravel with MySQL/PostgreSQL support.
# Dependencies in this stage are only required for building the final image.
# Node.js and asset building are handled in the Nginx stage, not here.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
unzip \
libpq-dev \
libonig-dev \
libssl-dev \
libxml2-dev \
libcurl4-openssl-dev \
libicu-dev \
libzip-dev \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
pdo_pgsql \
pgsql \
intl \
zip \
bcmath \
soap \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Set the working directory inside the container
WORKDIR /var/www
# Copy the entire Laravel application code into the container
# -----------------------------------------------------------
# In Laravel, `composer install` may trigger scripts
# needing access to application code.
# For example, the `post-autoload-dump` event might execute
# Artisan commands like `php artisan package:discover`. If the
# application code (including the `artisan` file) is not
# present, these commands will fail, leading to build errors.
#
# By copying the entire application code before running
# `composer install`, we ensure that all necessary files are
# available, allowing these scripts to run successfully.
# In other cases, it would be possible to copy composer files
# first, to leverage Docker's layer caching mechanism.
# -----------------------------------------------------------
COPY . /var/www
# Install Composer and dependencies
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist
# Stage 2: Production environment
FROM php:8.5-fpm AS production
# Install only runtime libraries needed in production
# libfcgi-bin and procps are required for the php-fpm-healthcheck script
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
libicu-dev \
libzip-dev \
libfcgi-bin \
procps \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Download and install php-fpm health check script
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
&& chmod +x /usr/local/bin/php-fpm-healthcheck
# Copy the initialization script
COPY ./docker/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Copy the initial storage structure
COPY ./storage /var/www/storage-init
# Copy PHP extensions and libraries from the builder stage
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/
# Use the recommended production PHP configuration
# -----------------------------------------------------------
# PHP provides development and production configurations.
# Here, we replace the default php.ini with the production
# version to apply settings optimized for performance and
# security in a live environment.
# -----------------------------------------------------------
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Keep the image-provided FPM global config intact and add pool overrides separately
COPY ./docker/common/php-fpm/conf.d/*.conf /usr/local/etc/php-fpm.d/
# Update the variables_order to include E (for ENV)
#RUN sed -i 's/variables_order = "GPCS"/variables_order = "EGPCS"/' "$PHP_INI_DIR/php.ini"
# Copy the application code and dependencies from the build stage
COPY --from=builder /var/www /var/www
# Set working directory
WORKDIR /var/www
# Ensure correct permissions
RUN chown -R www-data:www-data /var/www
# Switch to the non-privileged user to run the application
USER www-data
# Change the default command to run the entrypoint script
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# Expose port 9000 and start php-fpm server
EXPOSE 9000
CMD ["php-fpm"]
Create a Dockerfile for PHP-CLI (production)
For production, you often need a separate container to run Artisan commands, migrations, and other CLI tasks. In most cases you can run these commands by reusing existing PHP-FPM container:
$ docker compose -f compose.prod.yaml exec php-fpm php artisan route:list
If you need a separate CLI container with different extensions or strict separation of concerns, consider a php-cli Dockerfile:
# Stage 1: Build environment and Composer dependencies
FROM php:8.5-cli AS builder
# Install system dependencies and PHP extensions required for Laravel + MySQL/PostgreSQL support
# Some dependencies are required for PHP extensions only in the build stage
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
unzip \
libpq-dev \
libonig-dev \
libssl-dev \
libxml2-dev \
libcurl4-openssl-dev \
libicu-dev \
libzip-dev \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
pdo_pgsql \
pgsql \
intl \
zip \
bcmath \
soap \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Set the working directory inside the container
WORKDIR /var/www
# Copy the entire Laravel application code into the container
COPY . /var/www
# Install Composer and dependencies
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist
# Stage 2: Production environment
FROM php:8.5-cli
# Install client libraries required for php extensions in runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
libicu-dev \
libzip-dev \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Copy PHP extensions and libraries from the builder stage
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/
# Use the default production configuration for PHP runtime arguments
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Copy the application code and dependencies from the build stage
COPY --from=builder /var/www /var/www
# Set working directory
WORKDIR /var/www
# Ensure correct permissions
RUN chown -R www-data:www-data /var/www
# Switch to the non-privileged user to run the application
USER www-data
# Default command: Provide a bash shell to allow running any command
CMD ["bash"]
This Dockerfile is similar to the PHP-FPM Dockerfile, but it uses the php:8.5-cli image as the base image and sets up the container for running CLI commands.
Create a Dockerfile for Nginx (production)
Nginx serves as the web server for the Laravel application. You can include static assets directly to the container. Here's an example of possible Dockerfile for Nginx:
# docker/nginx/Dockerfile
# Stage 1: Build assets
FROM debian AS builder
# Install Node.js and build tools
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
nodejs \
npm \
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Set working directory
WORKDIR /var/www
# Copy Laravel application code
COPY . /var/www
# Install Node.js dependencies and build assets
RUN npm install && npm run build
# Stage 2: Nginx production image
FROM nginx:alpine
# Copy custom Nginx configuration
# -----------------------------------------------------------
# Replace the default Nginx configuration with our custom one
# that is optimized for serving a Laravel application.
# -----------------------------------------------------------
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf
# Copy Laravel's public assets from the builder stage
# -----------------------------------------------------------
# We only need the 'public' directory from our Laravel app.
# -----------------------------------------------------------
COPY --from=builder /var/www/public /var/www/public
# Set the working directory to the public folder
WORKDIR /var/www/public
# Expose port 80 and start Nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
This Dockerfile uses a multi-stage build to separate the asset building process from the final production image. The first stage installs Node.js and builds the assets, while the second stage sets up the Nginx production image with the optimized configuration and the built assets.
Create a Docker Compose configuration for production
To bring all the services together, create a compose.prod.yaml file that defines the services, volumes, and networks for the production environment. Here's an example configuration:
services:
web:
build:
context: .
dockerfile: ./docker/production/nginx/Dockerfile
restart: unless-stopped # Automatically restart unless the service is explicitly stopped
volumes:
# Mount the 'laravel-storage' volume to '/var/www/storage' inside the container.
# -----------------------------------------------------------
# This volume stores persistent data like uploaded files and cache.
# The ':ro' option mounts it as read-only in the 'web' service because Nginx only needs to read these files.
# The 'php-fpm' service mounts the same volume without ':ro' to allow write operations.
# -----------------------------------------------------------
- laravel-storage-production:/var/www/storage:ro
networks:
- laravel-production
ports:
# Map port 80 inside the container to the port specified by 'NGINX_PORT' on the host machine.
# -----------------------------------------------------------
# This allows external access to the Nginx web server running inside the container.
# For example, if 'NGINX_PORT' is set to '8080', accessing 'http://localhost:8080' will reach the application.
# -----------------------------------------------------------
- "${NGINX_PORT:-80}:80"
depends_on:
php-fpm:
condition: service_healthy # Wait for php-fpm health check
php-fpm:
# For the php-fpm service, we will create a custom image to install the necessary PHP extensions and setup proper permissions.
build:
context: .
dockerfile: ./docker/common/php-fpm/Dockerfile
target: production # Use the 'production' stage in the Dockerfile
restart: unless-stopped
volumes:
- laravel-storage-production:/var/www/storage # Mount the storage volume
env_file:
- .env
networks:
- laravel-production
healthcheck:
test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
interval: 10s
timeout: 5s
retries: 3
# The 'depends_on' attribute with 'condition: service_healthy' ensures that
# this service will not start until the 'postgres' service passes its health check.
# This prevents the application from trying to connect to the database before it's ready.
depends_on:
postgres:
condition: service_healthy
# The 'php-cli' service provides a command-line interface for running Artisan commands and other CLI tasks.
# -----------------------------------------------------------
# This is useful for running migrations, seeders, or any custom scripts.
# It shares the same codebase and environment as the 'php-fpm' service.
# -----------------------------------------------------------
php-cli:
build:
context: .
dockerfile: ./docker/php-cli/Dockerfile
tty: true # Enables an interactive terminal
stdin_open: true # Keeps standard input open for 'docker exec'
env_file:
- .env
networks:
- laravel-production
postgres:
image: postgres:18
restart: unless-stopped
user: postgres
ports:
- "${POSTGRES_PORT}:5432"
environment:
- POSTGRES_DB=${POSTGRES_DATABASE}
- POSTGRES_USER=${POSTGRES_USERNAME}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- postgres-data-production:/var/lib/postgresql
networks:
- laravel-production
# Health check for PostgreSQL
# -----------------------------------------------------------
# Health checks allow Docker to determine if a service is operational.
# The 'pg_isready' command checks if PostgreSQL is ready to accept connections.
# This prevents dependent services from starting before the database is ready.
# -----------------------------------------------------------
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:alpine
restart: unless-stopped # Automatically restart unless the service is explicitly stopped
networks:
- laravel-production
# Health check for Redis
# -----------------------------------------------------------
# Checks if Redis is responding to the 'PING' command.
# This ensures that the service is not only running but also operational.
# -----------------------------------------------------------
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
networks:
# Attach the service to the 'laravel-production' network.
# -----------------------------------------------------------
# This custom network allows all services within it to communicate using their service names as hostnames.
# For example, 'php-fpm' can connect to 'postgres' by using 'postgres' as the hostname.
# -----------------------------------------------------------
laravel-production:
volumes:
postgres-data-production:
laravel-storage-production:
[!NOTE] Ensure you have an
.envfile at the root of your Laravel project with the necessary configurations to match the Docker Compose setup.
Running your production environment
To start the production environment, run:
$ docker compose -f compose.prod.yaml up --build -d
This command will build and start all the services in detached mode, providing a scalable and production-ready setup for your Laravel application.
Summary
By setting up a Docker Compose environment for Laravel in production, you ensure that your application is optimized for performance, scalable, and secure. This setup makes deployments consistent and easier to manage, reducing the likelihood of errors due to differences between environments.
Laravel Development Setup with Docker Compose
This guide demonstrates how to configure a development environment for a Laravel application using Docker and Docker Compose. It builds on top of the production image for PHP-FPM and then adds developer-focused features—like Xdebug—to streamline debugging. By basing the development container on a known production image, you keep both environments closely aligned.
This setup includes PHP-FPM, Nginx, and PostgreSQL services (although you can easily swap PostgreSQL for another database, like MySQL or MariaDB). Everything runs in containers, so you can develop in isolation without altering your host system.
[!NOTE] To experiment with a ready-to-run configuration, download the Laravel Docker Examples repository. It contains pre-configured setups for both development and production.
Project structure
my-laravel-app/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── docker/
│ ├── common/
│ │ └── php-fpm/
│ │ └── Dockerfile
│ ├── development/
│ │ ├── php-fpm/
│ │ │ └── entrypoint.sh
│ │ ├── workspace/
│ │ │ └── Dockerfile
│ │ └── nginx
│ │ ├── Dockerfile
│ │ └── nginx.conf
│ └── production/
├── compose.dev.yaml
├── compose.prod.yaml
├── .dockerignore
├── .env
├── vendor/
├── ...
This layout represents a typical Laravel project, with Docker configurations stored in a unified docker directory. You’ll find two Compose files — compose.dev.yaml (for development) and compose.prod.yaml (for production) — to keep your environments separate and manageable.
The environment includes a workspace service, a sidecar container for tasks like building front-end assets, running Artisan commands, and other CLI tools your project may require. While this extra container may seem unusual, it’s a familiar pattern in solutions like Laravel Sail and Laradock. It also includes Xdebug to aid in debugging.
Create a Dockerfile for PHP-FPM
This Dockerfile extends the production image by installing Xdebug and adjusting user permissions to ease local development. That way, your development environment stays consistent with production while still offering extra debug features and improved file mounting.
# Builds a dev-only layer on top of the production image
FROM production AS development
# Use ARGs to define environment variables passed from the Docker build command or Docker Compose.
ARG XDEBUG_ENABLED=true
ARG XDEBUG_MODE=develop,coverage,debug,profile
ARG XDEBUG_HOST=host.docker.internal
ARG XDEBUG_IDE_KEY=DOCKER
ARG XDEBUG_LOG=/dev/stdout
ARG XDEBUG_LOG_LEVEL=0
USER root
# Configure Xdebug if enabled
RUN if [ "${XDEBUG_ENABLED}" = "true" ]; then \
pecl install xdebug && \
docker-php-ext-enable xdebug && \
echo "xdebug.mode=${XDEBUG_MODE}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.idekey=${XDEBUG_IDE_KEY}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.log=${XDEBUG_LOG}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.log_level=${XDEBUG_LOG_LEVEL}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.client_host=${XDEBUG_HOST}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \
echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \
fi
# Add ARGs for syncing permissions
ARG UID=1000
ARG GID=1000
# Create a new user with the specified UID and GID, reusing an existing group if GID exists
RUN if getent group ${GID}; then \
group_name=$(getent group ${GID} | cut -d: -f1); \
useradd -m -u ${UID} -g ${GID} -s /bin/bash www; \
else \
groupadd -g ${GID} www && \
useradd -m -u ${UID} -g www -s /bin/bash www; \
group_name=www; \
fi
# Dynamically update php-fpm to use the new user and group
RUN sed -i "s/user = www-data/user = www/g" /usr/local/etc/php-fpm.d/www.conf && \
sed -i "s/group = www-data/group = $group_name/g" /usr/local/etc/php-fpm.d/www.conf
# Set the working directory
WORKDIR /var/www
# Copy the entrypoint script
COPY ./docker/development/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Switch back to the non-privileged user to run the application
USER www-data
# Change the default command to run the entrypoint script
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# Expose port 9000 and start php-fpm server
EXPOSE 9000
CMD ["php-fpm"]
Create a Dockerfile for Workspace
A workspace container provides a dedicated shell for asset compilation, Artisan/Composer commands, and other CLI tasks. This approach follows patterns from Laravel Sail and Laradock, consolidating all development tools into one container for convenience.
# docker/development/workspace/Dockerfile
# Use the official PHP CLI image as the base
FROM php:8.5-cli
# Set environment variables for user and group ID
ARG UID=1000
ARG GID=1000
ARG NODE_VERSION=22.0.0
# Install system dependencies and build libraries
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
unzip \
libpq-dev \
libonig-dev \
libssl-dev \
libxml2-dev \
libcurl4-openssl-dev \
libicu-dev \
libzip-dev \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
pdo_pgsql \
pgsql \
intl \
zip \
bcmath \
soap \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Use ARG to define environment variables passed from the Docker build command or Docker Compose.
ARG XDEBUG_ENABLED
ARG XDEBUG_MODE
ARG XDEBUG_HOST
ARG XDEBUG_IDE_KEY
ARG XDEBUG_LOG
ARG XDEBUG_LOG_LEVEL
# Configure Xdebug if enabled
RUN if [ "${XDEBUG_ENABLED}" = "true" ]; then \
pecl install xdebug && \
docker-php-ext-enable xdebug && \
echo "xdebug.mode=${XDEBUG_MODE}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.idekey=${XDEBUG_IDE_KEY}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.log=${XDEBUG_LOG}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.log_level=${XDEBUG_LOG_LEVEL}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
echo "xdebug.client_host=${XDEBUG_HOST}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \
echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \
fi
# If the group already exists, use it; otherwise, create the 'www' group
RUN if getent group ${GID}; then \
useradd -m -u ${UID} -g ${GID} -s /bin/bash www; \
else \
groupadd -g ${GID} www && \
useradd -m -u ${UID} -g www -s /bin/bash www; \
fi && \
usermod -aG sudo www && \
echo 'www ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Switch to the non-root user to install NVM and Node.js
USER www
# Install NVM (Node Version Manager) as the www user
RUN export NVM_DIR="$HOME/.nvm" && \
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash && \
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && \
nvm install ${NODE_VERSION} && \
nvm alias default ${NODE_VERSION} && \
nvm use default
# Ensure NVM is available for all future shells
RUN echo 'export NVM_DIR="$HOME/.nvm"' >> /home/www/.bashrc && \
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> /home/www/.bashrc && \
echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"' >> /home/www/.bashrc
# Set the working directory
WORKDIR /var/www
# Override the entrypoint to avoid the default php entrypoint
ENTRYPOINT []
# Default command to keep the container running
CMD ["bash"]
[!NOTE] If you prefer a one-service-per-container approach, simply omit the workspace container and run separate containers for each task. For example, you could use a dedicated
php-clicontainer for your PHP scripts, and anodecontainer to handle the asset building.
Create a Docker Compose configuration for development
Here's the compose.yaml file to set up the development environment:
services:
web:
image: nginx:latest # Using the default Nginx image with custom configuration.
volumes:
# Mount the application code for live updates
- ./:/var/www
# Mount the Nginx configuration file
- ./docker/development/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
# Map port 80 inside the container to the port specified by 'NGINX_PORT' on the host machine
- "80:80"
environment:
- NGINX_HOST=localhost
networks:
- laravel-development
depends_on:
php-fpm:
condition: service_started # Wait for php-fpm to start
php-fpm:
# For the php-fpm service, we will use our common PHP-FPM Dockerfile with the development target
build:
context: .
dockerfile: ./docker/common/php-fpm/Dockerfile
target: development
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
XDEBUG_ENABLED: ${XDEBUG_ENABLED:-true}
XDEBUG_MODE: develop,coverage,debug,profile
XDEBUG_HOST: ${XDEBUG_HOST:-host.docker.internal}
XDEBUG_IDE_KEY: ${XDEBUG_IDE_KEY:-DOCKER}
XDEBUG_LOG: /dev/stdout
XDEBUG_LOG_LEVEL: 0
env_file:
# Load the environment variables from the Laravel application
- .env
user: "${UID:-1000}:${GID:-1000}"
volumes:
# Mount the application code for live updates
- ./:/var/www
networks:
- laravel-development
depends_on:
postgres:
condition: service_started # Wait for postgres to start
workspace:
# For the workspace service, we will also create a custom image to install and setup all the necessary stuff.
build:
context: .
dockerfile: ./docker/development/workspace/Dockerfile
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
XDEBUG_ENABLED: ${XDEBUG_ENABLED:-true}
XDEBUG_MODE: develop,coverage,debug,profile
XDEBUG_HOST: ${XDEBUG_HOST:-host.docker.internal}
XDEBUG_IDE_KEY: ${XDEBUG_IDE_KEY:-DOCKER}
XDEBUG_LOG: /dev/stdout
XDEBUG_LOG_LEVEL: 0
tty: true # Enables an interactive terminal
stdin_open: true # Keeps standard input open for 'docker exec'
env_file:
- .env
volumes:
- ./:/var/www
networks:
- laravel-development
postgres:
image: postgres:18
ports:
- "${POSTGRES_PORT:-5432}:5432"
environment:
- POSTGRES_DB=app
- POSTGRES_USER=laravel
- POSTGRES_PASSWORD=secret
volumes:
- postgres-data-development:/var/lib/postgresql
networks:
- laravel-development
redis:
image: redis:alpine
networks:
- laravel-development
networks:
laravel-development:
volumes:
postgres-data-development:
[!NOTE] Ensure you have an
.envfile at the root of your Laravel project with the necessary configurations. You can use the.env.examplefile 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 -dThis 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-localCLI on your system. Theawslocalcommand 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-localCreate a new S3 bucket within the LocalStack environment with the following command:
$ awslocal s3 mb s3://mysamplebucketThe command
s3 mb s3://mysamplebuckettells the AWS CLI to create a new S3 bucket (mb stands formake bucket) namedmysamplebucket.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
mysamplebucketfor 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
.envfile located in the backend/ directory already contains placeholder credentials and configuration values that LocalStack uses to emulate AWS services. TheAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYare placeholder credentials, whileS3_BUCKET_NAMEandS3_ENDPOINT_URLare configuration settings. No changes are needed as these values are already correctly set for LocalStack.[!TIP]
Given that you’re running Mongo in a Docker container and the backend Node app is running natively on your host, ensure that
MONGODB_URI=mongodb://localhost:27017/todosis set in your.envfile.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-1While the AWS SDK might typically use environment variables starting with
AWS_, this specific application directly references the followingS3_*variables in the index.js file (under thebackend/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.jsYou 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:
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, andlocalstack. Thebackendandfrontendservices are your Node.js applications, whilemongodbprovides a database andlocalstacksimulates AWS services like S3.The
backendservice depends onlocalstackandmongodbservices, 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. Themongodbservice uses a persistent volume for data storage, andlocalstackis 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
.envfile under thebackend/directory to have the resources connect using the internal network names.[!TIP] Given the previous Compose file, the app would connect to LocalStack using the hostname
localstackwhile Mongo would connect using the hostnamemongodb.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 --buildAfter 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://mysamplebucketThe command creates an S3 bucket named
mysamplebucket.[!TIP]
You can automate this step by placing a shell script (for example,
init.sh) under the local./localstackdirectory. Make sure the script is executable (chmod +x ./localstack/init.sh). LocalStack runs files mounted in/etc/localstack/init/ready.donce it is ready. See LocalStack init hooks for more details.Open http://localhost:5173 to access the complete to-do list application and start uploading images to the Amazon S3 bucket.
[!TIP] To optimize performance and reduce upload times during development, consider uploading smaller image files. Larger images may take longer to process and could impact the overall responsiveness of the application.
Recap
This guide has walked you through setting up a local development environment using LocalStack and Docker. You’ve learned how to test AWS-based applications locally, reducing costs and increasing the efficiency of your development workflow.
Run Mistral Vibe in a Docker Sandbox
Mistral Vibe is Mistral's open source coding agent. This guide shows how to package it as a Docker Sandbox agent so it runs in an isolated microVM instead of directly on your host. The agent reaches Mistral through the sandbox proxy, so your API key stays on the host.
Install a pinned Vibe version when you build the kit, then reuse that image for each sandbox. The kit also declares the network access and credentials Vibe needs at runtime.
In this guide, you'll learn how to:
- 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.
$ 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.
[!NOTE] New to Docker? Start with the Docker basics guide to get familiar with key concepts like images, containers, and Dockerfiles.
Overview
This guide walks you through containerizing a Next.js application with Docker. You'll learn how to create a production-ready Docker image using best practices that improve performance, security, scalability, and deployment efficiency.
By the end of this guide, you will:
- 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).
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored to your application.
Step 1: Configure Next.js and create the Dockerfile
Before creating a Dockerfile, choose a base image: the Node.js Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog. Choosing DHI gives you a production-ready, lightweight, and secure image. For more information, see Docker Hardened Images.
[!IMPORTANT] This guide uses stable Node.js LTS image tags that are considered secure when the guide is written. Because new releases and security patches are published regularly, always review the official Node.js Docker images and select a secure, up-to-date version before building or deploying.
1.1 Next.js with standalone output
Standalone output (output: "standalone") makes Next.js build a self-contained output that includes only the files and dependencies needed to run the application. A single node server.js can serve the app, which is ideal for Docker and supports server-side rendering, API routes, and incremental static regeneration. For details, see the Next.js output configuration documentation (including the "standalone" option).
The container runs the Next.js server with Node.js on port 3000.
Configure Next.js — Open or create next.config.ts in your project root:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
Choose either a Docker Hardened Image or the Docker Official Image, then create a Dockerfile using the content from the selected tab below.
{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}}
Docker Hardened Images (DHIs) are available for Node.js in the Docker Hardened Images catalog. For more information, see the DHI quickstart guide.
-
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
Dockerfilewith the following contents. TheFROMinstructions usedhi.io/node:24-alpine3.22-dev. Check the Docker Hardened Images catalog for the latest versions and update the image tags as needed for security and compatibility.# ============================================ # Stage 1: Dependencies Installation Stage # ============================================ # IMPORTANT: Docker Hardened Image (DHI) Version Maintenance # This Dockerfile uses dhi.io/node. Regularly validate and update to the latest DHI versions in the catalog for security and compatibility. FROM dhi.io/node:24-alpine3.22-dev AS dependencies # Set working directory WORKDIR /app # Copy package-related files first to leverage Docker's caching mechanism COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ # Install project dependencies with frozen lockfile for reproducible builds RUN --mount=type=cache,target=/root/.npm \ --mount=type=cache,target=/usr/local/share/.cache/yarn \ --mount=type=cache,target=/root/.local/share/pnpm/store \ if [ -f package-lock.json ]; then \ npm ci --no-audit --no-fund; \ elif [ -f yarn.lock ]; then \ corepack enable yarn && yarn install --frozen-lockfile --production=false; \ elif [ -f pnpm-lock.yaml ]; then \ corepack enable pnpm && pnpm install --frozen-lockfile; \ else \ echo "No lockfile found." && exit 1; \ fi # ============================================ # Stage 2: Build Next.js application in standalone mode # ============================================ FROM dhi.io/node:24-alpine3.22-dev AS builder # Set working directory WORKDIR /app # Copy project dependencies from dependencies stage COPY --from=dependencies /app/node_modules ./node_modules # Copy application source code COPY . . ENV NODE_ENV=production # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. # ENV NEXT_TELEMETRY_DISABLED=1 # Build Next.js application # If you want to speed up Docker rebuilds, you can cache the build artifacts # by adding: --mount=type=cache,target=/app/.next/cache # This caches the .next/cache directory across builds, but it also prevents # .next/cache/fetch-cache from being included in the final image, meaning # cached fetch responses from the build won't be available at runtime. RUN if [ -f package-lock.json ]; then \ npm run build; \ elif [ -f yarn.lock ]; then \ corepack enable yarn && yarn build; \ elif [ -f pnpm-lock.yaml ]; then \ corepack enable pnpm && pnpm build; \ else \ echo "No lockfile found." && exit 1; \ fi # ============================================ # Stage 3: Run Next.js application # ============================================ FROM dhi.io/node:24-alpine3.22-dev AS runner # Set working directory WORKDIR /app # Set production environment variables ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME="0.0.0.0" # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the run time. # ENV NEXT_TELEMETRY_DISABLED=1 # Copy production assets COPY --from=builder --chown=node:node /app/public ./public # Set the correct permission for prerender cache RUN mkdir .next RUN chown node:node .next # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static # If you want to persist the fetch cache generated during the build so that # cached responses are available immediately on startup, uncomment this line: # COPY --from=builder --chown=node:node /app/.next/cache ./.next/cache # Switch to non-root user for security best practices USER node # Expose port 3000 to allow HTTP traffic EXPOSE 3000 # Start Next.js standalone server CMD ["node", "server.js"]
{{< /tab >}} {{< tab name="Using the Docker Official Image" >}}
Create a file named Dockerfile with the following contents (uses node):
# ============================================
# Stage 1: Dependencies Installation Stage
# ============================================
ARG NODE_VERSION=24.14.0-slim
FROM node:${NODE_VERSION} AS dependencies
# Set working directory
WORKDIR /app
# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
# Install project dependencies with frozen lockfile for reproducible builds
RUN --mount=type=cache,target=/root/.npm \
--mount=type=cache,target=/usr/local/share/.cache/yarn \
--mount=type=cache,target=/root/.local/share/pnpm/store \
if [ -f package-lock.json ]; then \
npm ci --no-audit --no-fund; \
elif [ -f yarn.lock ]; then \
corepack enable yarn && yarn install --frozen-lockfile --production=false; \
elif [ -f pnpm-lock.yaml ]; then \
corepack enable pnpm && pnpm install --frozen-lockfile; \
else \
echo "No lockfile found." && exit 1; \
fi
# ============================================
# Stage 2: Build Next.js application in standalone mode
# ============================================
FROM node:${NODE_VERSION} AS builder
# Set working directory
WORKDIR /app
# Copy project dependencies from dependencies stage
COPY --from=dependencies /app/node_modules ./node_modules
# Copy application source code
COPY . .
ENV NODE_ENV=production
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
# Build Next.js application
# If you want to speed up Docker rebuilds, you can cache the build artifacts
# by adding: --mount=type=cache,target=/app/.next/cache
# This caches the .next/cache directory across builds, but it also prevents
# .next/cache/fetch-cache from being included in the final image, meaning
# cached fetch responses from the build won't be available at runtime.
RUN if [ -f package-lock.json ]; then \
npm run build; \
elif [ -f yarn.lock ]; then \
corepack enable yarn && yarn build; \
elif [ -f pnpm-lock.yaml ]; then \
corepack enable pnpm && pnpm build; \
else \
echo "No lockfile found." && exit 1; \
fi
# ============================================
# Stage 3: Run Next.js application
# ============================================
FROM node:${NODE_VERSION} AS runner
# Set working directory
WORKDIR /app
# Set production environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the run time.
# ENV NEXT_TELEMETRY_DISABLED=1
# Copy production assets
COPY --from=builder --chown=node:node /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown node:node .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
# If you want to persist the fetch cache generated during the build so that
# cached responses are available immediately on startup, uncomment this line:
# COPY --from=builder --chown=node:node /app/.next/cache ./.next/cache
# Switch to non-root user for security best practices
USER node
# Expose port 3000 to allow HTTP traffic
EXPOSE 3000
# Start Next.js standalone server
CMD ["node", "server.js"]
[!NOTE] This Dockerfile uses three stages:
dependencies,builder, andrunner. The final image runsnode server.jsand 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
Dockerfilewith the following contents. TheFROMinstructions use Docker Hardened Images:dhi.io/node:24-alpine3.22-devanddhi.io/nginx:1.28.0-alpine3.21-dev. Check the Docker Hardened Images catalog for the latest versions and update the image tags as needed for security and compatibility.# ============================================ # Stage 1: Dependencies Installation Stage # ============================================ # IMPORTANT: Docker Hardened Image (DHI) Version Maintenance # This Dockerfile uses dhi.io/node and dhi.io/nginx. Regularly validate and update to the latest DHI versions in the catalog for security and compatibility. FROM dhi.io/node:24-alpine3.22-dev AS dependencies # Set the working directory WORKDIR /app # Copy package-related files first to leverage Docker's caching mechanism COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ # Install project dependencies with frozen lockfile for reproducible builds RUN --mount=type=cache,target=/root/.npm \ --mount=type=cache,target=/usr/local/share/.cache/yarn \ --mount=type=cache,target=/root/.local/share/pnpm/store \ if [ -f package-lock.json ]; then \ npm ci --no-audit --no-fund; \ elif [ -f yarn.lock ]; then \ corepack enable yarn && yarn install --frozen-lockfile --production=false; \ elif [ -f pnpm-lock.yaml ]; then \ corepack enable pnpm && pnpm install --frozen-lockfile; \ else \ echo "No lockfile found." && exit 1; \ fi # ============================================ # Stage 2: Build Next.js Application # ============================================ FROM dhi.io/node:24-alpine3.22-dev AS builder # Set the working directory WORKDIR /app # Copy project dependencies from dependencies stage COPY --from=dependencies /app/node_modules ./node_modules # Copy application source code COPY . . ENV NODE_ENV=production # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. # ENV NEXT_TELEMETRY_DISABLED=1 # Build Next.js application RUN --mount=type=cache,target=/app/.next/cache \ if [ -f package-lock.json ]; then \ npm run build; \ elif [ -f yarn.lock ]; then \ corepack enable yarn && yarn build; \ elif [ -f pnpm-lock.yaml ]; then \ corepack enable pnpm && pnpm build; \ else \ echo "No lockfile found." && exit 1; \ fi # ========================================= # Stage 3: Serve Static Files with Nginx # ========================================= FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner # Set the working directory WORKDIR /app # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the run time. # ENV NEXT_TELEMETRY_DISABLED=1 # Copy custom Nginx config COPY nginx.conf /etc/nginx/nginx.conf # Copy the static build output from the build stage to Nginx's default HTML serving directory COPY --chown=nginx:nginx --from=builder /app/out /usr/share/nginx/html # Non-root user for security best practices USER nginx # Expose port 8080 to allow HTTP traffic EXPOSE 8080 # Start Nginx directly with custom config ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"] CMD ["-g", "daemon off;"]
{{< /tab >}} {{< tab name="Using the Docker Official Image" >}}
Create a file named Dockerfile with the following contents (uses node and nginxinc/nginx-unprivileged):
# ============================================
# Stage 1: Dependencies Installation Stage
# ============================================
ARG NODE_VERSION=24.14.0-slim
ARG NGINXINC_IMAGE_TAG=alpine3.22
FROM node:${NODE_VERSION} AS dependencies
# Set the working directory
WORKDIR /app
# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
# Install project dependencies with frozen lockfile for reproducible builds
RUN --mount=type=cache,target=/root/.npm \
--mount=type=cache,target=/usr/local/share/.cache/yarn \
--mount=type=cache,target=/root/.local/share/pnpm/store \
if [ -f package-lock.json ]; then \
npm ci --no-audit --no-fund; \
elif [ -f yarn.lock ]; then \
corepack enable yarn && yarn install --frozen-lockfile --production=false; \
elif [ -f pnpm-lock.yaml ]; then \
corepack enable pnpm && pnpm install --frozen-lockfile; \
else \
echo "No lockfile found." && exit 1; \
fi
# ============================================
# Stage 2: Build Next.js Application
# ============================================
FROM node:${NODE_VERSION} AS builder
# Set the working directory
WORKDIR /app
# Copy project dependencies from dependencies stage
COPY --from=dependencies /app/node_modules ./node_modules
# Copy application source code
COPY . .
ENV NODE_ENV=production
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
# Build Next.js application
RUN --mount=type=cache,target=/app/.next/cache \
if [ -f package-lock.json ]; then \
npm run build; \
elif [ -f yarn.lock ]; then \
corepack enable yarn && yarn build; \
elif [ -f pnpm-lock.yaml ]; then \
corepack enable pnpm && pnpm build; \
else \
echo "No lockfile found." && exit 1; \
fi
# =========================================
# Stage 3: Serve Static Files with Nginx
# =========================================
FROM nginxinc/nginx-unprivileged:${NGINXINC_IMAGE_TAG} AS runner
# Set the working directory
WORKDIR /app
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the run time.
# ENV NEXT_TELEMETRY_DISABLED=1
# Copy custom Nginx config
COPY nginx.conf /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --from=builder /app/out /usr/share/nginx/html
# Non-root user for security best practices
USER nginx
# Expose port 8080 to allow HTTP traffic
EXPOSE 8080
# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]
[!NOTE] This guide uses nginx-unprivileged instead of the standard Nginx image to run as a non-root user, following security best practices.
{{< /tab >}} {{< /tabs >}}
-
Create
nginx.conf(required for export output only) — Create a file namednginx.confin the root of your project:# Minimal Nginx config for static Next.js app worker_processes 1; # Store PID in /tmp (always writable) pid /tmp/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # Disable logging to avoid permission issues access_log off; error_log /dev/stderr; # Optimize static file serving sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; # Gzip compression gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_min_length 256; server { listen 8080; server_name localhost; # Serve static files root /usr/share/nginx/html; index index.html; # Handle Next.js static export routing # See: https://nextjs.org/docs/app/guides/static-exports#deploying location / { try_files $uri $uri.html $uri/ =404; } # This is necessary when `trailingSlash: false` (default). # You can omit this when `trailingSlash: true` in next.config. # Handles nested routes like /blog/post -> /blog/post.html location ~ ^/(.+)/$ { rewrite ^/(.+)/$ /$1.html break; } # Serve Next.js static assets location ~ ^/_next/ { try_files $uri =404; expires 1y; add_header Cache-Control "public, immutable"; } # Optional 404 handling error_page 404 /404.html; location = /404.html { internal; } } }[!NOTE] Export uses port 8080. For more details, see the Next.js output configuration and Nginx documentation.
Step 2: Create the compose.yaml file
Create a file named compose.yaml with the following contents:
services:
server:
build:
context: .
ports:
- 3000:3000
[!NOTE] If using export output (Nginx), change the port mapping to
8080:8080.
Step 3: Create the .dockerignore file
The .dockerignore file tells Docker which files and folders to exclude when building the image.
[!NOTE] This helps:
- Reduce image size
- Speed up the build process
- Prevent sensitive or unnecessary files (like
.env,.git, ornode_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.
[!NOTE] The
docker buildcommand 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
[!NOTE] For more information about running containers, see the
docker runCLI reference and thedocker stopCLI 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-standaloneservice builds and runs your production Next.js app using the standalone output. - The
nextjs-devservice runs your Next.js development server with hot module replacement. watchtriggers file sync with Compose Watch.WATCHPACK_POLLING=trueensures file changes are detected properly inside Docker.- The
rebuildaction forpackage.jsonensures dependencies are reinstalled when the file changes.
[!NOTE] For more details, see the official guide: Use Compose Watch.
Step 3: Configure Next.js for Docker development
Next.js works well inside Docker containers out of the box, but there are a few configurations that can improve the development experience.
The next.config.ts file you created during containerization already includes the output: "standalone" option for production. For development, Next.js automatically uses its built-in development server with hot reloading enabled.
[!NOTE] The Next.js development server automatically:
- 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=trueenvironment 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.tsxfile in your text editor (orsrc/app/page.tsxif your project uses asrcdirectory). -
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";
[!NOTE] Vitest works well with Next.js and provides fast execution and ESM support. For more details, see the Next.js testing documentation and Vitest docs.
Step 3: Update compose.yaml
Add nextjs-test and nextjs-lint services to your compose.yaml file. In the sample project these services use the tools profile so they don't start with a normal docker compose up. Both reuse Dockerfile.dev and run the test or lint command:
services:
nextjs-prod-standalone:
build:
context: .
dockerfile: Dockerfile
image: nextjs-sample:prod
container_name: nextjs-sample-prod
ports:
- "3000:3000"
nextjs-dev:
build:
context: .
dockerfile: Dockerfile.dev
image: nextjs-sample:dev
container_name: nextjs-sample-dev
ports:
- "3000:3000"
environment:
- WATCHPACK_POLLING=true
develop:
watch:
- action: sync
path: .
target: /app
ignore:
- node_modules/
- .next/
- action: rebuild
path: package.json
nextjs-test:
build:
context: .
dockerfile: Dockerfile.dev
image: nextjs-sample:dev
container_name: nextjs-sample-test
command:
[
"sh",
"-c",
"if [ -f package-lock.json ]; then npm run test:run 2>/dev/null || npm run test -- --run; elif [ -f yarn.lock ]; then yarn test:run 2>/dev/null || yarn test --run; elif [ -f pnpm-lock.yaml ]; then pnpm run test:run; else npm run test -- --run; fi",
]
profiles:
- tools
nextjs-lint:
build:
context: .
dockerfile: Dockerfile.dev
image: nextjs-sample:dev
container_name: nextjs-sample-lint
command:
[
"sh",
"-c",
"if [ -f package-lock.json ]; then npm run lint; elif [ -f yarn.lock ]; then yarn lint; elif [ -f pnpm-lock.yaml ]; then pnpm lint; else npm run lint; fi",
]
profiles:
- tools
The nextjs-test and nextjs-lint services reuse the same Dockerfile.dev used for development and override the default command to run tests or lint. The profiles: [tools] means these services only run when you use the --profile tools option.
After completing the previous steps, your project directory should contain:
├── docker-nextjs-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ ├── vitest.config.ts
│ ├── vitest.setup.ts
│ └── next.config.ts
Step 4: Run the tests
To execute your test suite inside the container, run from your project root:
$ docker compose --profile tools run --rm nextjs-test
This command will:
- Start the
nextjs-testservice (because of--profile tools). - Run your test script (
test:runortest -- --run) in the same environment as development. - Remove the container after the tests complete (
docker compose run --rm).
[!NOTE] For more information about Compose commands and profiles, see the Compose CLI reference.
Step 5: Run lint in the container
To run your linter (e.g. ESLint) inside the container, use the nextjs-lint service with the same tools profile:
$ docker compose --profile tools run --rm nextjs-lint
This command will:
- Start the
nextjs-lintservice (because of--profile tools). - Run your lint script (
npm run lint,yarn lint, orpnpm lintdepending 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-testandnextjs-lintservices incompose.yaml(withtoolsprofile) to isolate test and lint execution. - Reused the development
Dockerfile.devto 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 runCLI 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.
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored to your application.
{{< files name="nodejs-docker-example" >}}
{{< file path="src/index.ts" >}}
// A minimal Express application.
// The root endpoint (GET /) returns a JSON greeting.
// See https://expressjs.com/ for the framework reference.
import express, { type Request, type Response } from "express";
const app = express();
const port = parseInt(process.env.PORT ?? "3000", 10);
app.get("/", (_req: Request, res: Response) => {
res.json({ message: "Hello World" });
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
{{< /file >}}
{{< file path="package.json" >}}
{
"name": "nodejs-docker-example",
"version": "1.0.0",
"description": "A minimal Node.js TypeScript application.",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"express": "^4.21.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
}
{{< /file >}}
{{< file path="tsconfig.json" >}}
{
// TypeScript compiler configuration for the Node.js application.
// Compiles src/ to dist/ as CommonJS modules targeting ES2022.
// See https://www.typescriptlang.org/tsconfig/ for all options.
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
{{< /file >}}
{{< file path="Dockerfile" status="new" >}}
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/
# Builder stage: install all dependencies and compile TypeScript.
FROM dhi.io/node:24-alpine3.23-dev AS builder
WORKDIR /app
# Install dependencies as a separate step to take advantage of Docker's
# caching. Leverage a cache mount to /root/.npm to speed up subsequent
# builds. Leverage a bind mount to package.json to avoid having to copy
# it into this layer.
RUN --mount=type=cache,target=/root/.npm \
--mount=type=bind,source=package.json,target=package.json \
npm install
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
# --mount=type=bind,source=package.json,target=package.json \
# --mount=type=bind,source=package-lock.json,target=package-lock.json \
# npm ci
# Copy the source code into the container and compile TypeScript.
COPY . .
RUN npm run build
# Deps stage: install production dependencies only.
FROM dhi.io/node:24-alpine3.23-dev AS deps
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm \
--mount=type=bind,source=package.json,target=package.json \
npm install --omit=dev
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
# --mount=type=bind,source=package.json,target=package.json \
# --mount=type=bind,source=package-lock.json,target=package-lock.json \
# npm ci --omit=dev
# Runner stage: minimal runtime image with compiled app and production deps.
FROM dhi.io/node:24-alpine3.23 AS runner
ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
# Expose the port that the application listens on.
EXPOSE 3000
# Run the application.
CMD ["node", "dist/index.js"]
{{< /file >}}
{{< file path="compose.yaml" status="new" >}}
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
ports:
- 3000:3000
{{< /file >}}
{{< file path=".dockerignore" status="new" >}}
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
node_modules/
dist/
.env
.git
.gitignore
.DS_Store
npm-debug.log*
coverage/
db/
{{< /file >}}
{{< file path=".gitignore" >}}
# Files and directories that Git should ignore. Covers Node.js dependencies,
# TypeScript build output, environment files, and common editor artifacts.
# See https://git-scm.com/docs/gitignore for syntax reference.
node_modules/
dist/
.env
*.log
.DS_Store
coverage/
db/password.txt
{{< /file >}}
{{< /files >}}
The Dockerfile uses three stages. The builder stage installs all dependencies and compiles TypeScript. The deps stage does a fresh install of production-only dependencies. The runner stage copies the compiled output and production node_modules into a minimal runtime image that contains only Node.js.
To learn more about each file, see the following:
Run the application
Inside the nodejs-docker-example directory, run the following command in a
terminal.
$ docker compose up --build
Open a browser and view the application at http://localhost:3000. You should see {"message":"Hello World"}.
In the terminal, press ctrl+c to stop the application.
Run the application in the background
You can run the application detached from the terminal by adding the -d
option. Inside the nodejs-docker-example directory, run the following command
in a terminal.
$ docker compose up --build -d
Open a browser and view the application at http://localhost:3000.
In the terminal, run the following command to stop the application.
$ docker compose down
For more information about Compose commands, see the Compose CLI reference.
Use containers for Node.js development
Prerequisites
Complete Containerize a Node.js application.
Overview
Once your application runs in a container, the next step is making the container part of your everyday development workflow. Code changes should show up quickly, and services your app depends on, like databases, should run right alongside it.
In this section, you'll adapt the Dockerfile for local development by renaming the builder stage to dev and pointing Compose at it. You'll also update the application to connect to a PostgreSQL database, add a database service to compose.yaml, persist data in a named volume, enable Compose Watch so changes in your editor are picked up without a manual rebuild, and set up Node.js debugging so you can attach VS Code or Chrome DevTools to the running container.
Update the application
You'll update your application to connect to a PostgreSQL database. Continue working in your nodejs-docker-example directory.
Replace src/index.ts and package.json with the following contents. The file browser shows only the files that change in this step.
[!NOTE]
The application won't run yet after this step. It tries to connect to a PostgreSQL database that doesn't exist. The next two sections add the database service and the Docker configuration needed to run everything together.
{{< files name="nodejs-docker-example" >}}
{{< file path="src/index.ts" status="modified" >}}
// Express application backed by a PostgreSQL database.
// Creates a heroes table at startup.
// Endpoints: GET / (greeting), GET /health (health check), POST /heroes/ (create), GET /heroes/ (list).
// See https://expressjs.com/ and https://node-postgres.com/
import express, { type Request, type Response } from "express";
import { Pool } from "pg";
import { readFileSync } from "fs";
const app = express();
const port = parseInt(process.env.PORT ?? "3000", 10);
app.use(express.json());
function getPassword(): string {
const passwordFile = process.env.POSTGRES_PASSWORD_FILE;
if (passwordFile) {
return readFileSync(passwordFile, "utf8").trim();
}
return process.env.POSTGRES_PASSWORD ?? "";
}
const pool = new Pool({
host: process.env.POSTGRES_SERVER,
port: 5432,
database: process.env.POSTGRES_DB,
user: process.env.POSTGRES_USER,
password: getPassword(),
});
pool
.query(
`CREATE TABLE IF NOT EXISTS heroes (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
secret_name TEXT NOT NULL,
age INTEGER
)`,
)
.catch(console.error);
app.get("/", (_req: Request, res: Response) => {
res.json({ message: "Hello World" });
});
app.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok" });
});
app.post("/heroes/", async (req: Request, res: Response) => {
const { name, secret_name, age } = req.body as {
name: string;
secret_name: string;
age?: number;
};
const result = await pool.query(
"INSERT INTO heroes (name, secret_name, age) VALUES ($1, $2, $3) RETURNING *",
[name, secret_name, age],
);
res.json(result.rows[0]);
});
app.get("/heroes/", async (_req: Request, res: Response) => {
const result = await pool.query("SELECT * FROM heroes");
res.json(result.rows);
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
{{< /file >}}
{{< file path="package.json" status="modified" hl_lines="13,18" >}}
{
"name": "nodejs-docker-example",
"version": "1.0.0",
"description": "A minimal Node.js TypeScript application.",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"express": "^4.21.2",
"pg": "^8.16.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
}
{{< /file >}}
{{< /files >}}
Update Docker assets
Replace Dockerfile and compose.yaml with the following.
{{< files name="nodejs-docker-example" >}}
{{< file path="Dockerfile" status="modified" hl_lines="12,34,37,63" >}}
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/
# Development stage: install all dependencies, compile TypeScript, and
# serve with hot-reload. Used directly in development via compose.yaml.
FROM dhi.io/node:24-alpine3.23-dev AS dev
WORKDIR /app
# Install dependencies as a separate step to take advantage of Docker's
# caching. Leverage a cache mount to /root/.npm to speed up subsequent
# builds. Leverage a bind mount to package.json to avoid having to copy
# it into this layer.
RUN --mount=type=cache,target=/root/.npm \
--mount=type=bind,source=package.json,target=package.json \
npm install
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
# --mount=type=bind,source=package.json,target=package.json \
# --mount=type=bind,source=package-lock.json,target=package-lock.json \
# npm ci
# Copy the source code into the container and compile TypeScript.
COPY . .
RUN npm run build
# Expose the port that the application listens on.
EXPOSE 3000
# Run the application in development mode.
CMD ["npm", "run", "dev"]
# Deps stage: install production dependencies only.
FROM dhi.io/node:24-alpine3.23-dev AS deps
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm \
--mount=type=bind,source=package.json,target=package.json \
npm install --omit=dev
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
# --mount=type=bind,source=package.json,target=package.json \
# --mount=type=bind,source=package-lock.json,target=package-lock.json \
# npm ci --omit=dev
# Runner stage: minimal runtime image with compiled app and production deps.
FROM dhi.io/node:24-alpine3.23 AS runner
ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=dev --chown=node:node /app/dist ./dist
# Expose the port that the application listens on.
EXPOSE 3000
# Run the application.
CMD ["node", "dist/index.js"]
{{< /file >}}
{{< file path="compose.yaml" status="modified" hl_lines="8" >}}
services:
# Application service. The `target: dev` line builds the development
# image (includes tsx and dev tooling); the runner stage of the
# Dockerfile is unused in development.
server:
build:
context: .
target: dev
ports:
- 3000:3000
{{< /file >}}
{{< /files >}}
About these changes
The builder stage from containerize is renamed to dev and gains EXPOSE 3000 and CMD ["npm", "run", "dev"], which runs tsx watch for hot-reload. The deps and runner stages are unchanged.
In compose.yaml, the new target: dev line tells Compose to build and run the dev stage during development. Unlike the production image, the development image includes tsx and other dev tooling. If you need a shell in a running production container, use Docker Debug instead.
The build step runs tsc, which compiles each TypeScript file into a corresponding JavaScript file. esbuild is a popular alternative that bundles everything into a single output file and builds significantly faster. To switch, replace the tsc call in package.json with an esbuild command and update the COPY --from=dev path in the runner stage to match esbuild's output.
Add a local database and persist data
You can use containers to set up local services, like a database. In this section, you'll update the compose.yaml file to define a database service and a volume to persist data, and add a db/password.txt file that holds the database password.
{{< files name="nodejs-docker-example" >}}
{{< file path="compose.yaml" status="modified" hl_lines="11-46" >}}
services:
# Application service. The `target: dev` line builds the development
# image (includes tsx and dev tooling); the runner stage of the
# Dockerfile is unused in development.
server:
build:
context: .
target: dev
ports:
- 3000:3000
environment:
- POSTGRES_SERVER=db
- POSTGRES_USER=postgres
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
depends_on:
db:
condition: service_healthy
secrets:
- db-password
# Database service. Reads the password from a Docker secret mounted at
# /run/secrets/db-password. Compose waits for the healthcheck to pass
# before starting the server, via the server's depends_on.
db:
image: dhi.io/postgres:18
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- 5432
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
{{< /file >}}
{{< file path="db/password.txt" status="new" >}}
mysecretpassword
{{< /file >}}
{{< /files >}}
[!NOTE]
To learn more about the instructions in the Compose file, see Compose file reference.
Now, run the following docker compose up command to start your application.
$ docker compose up --build
Now test your API endpoint. Open a new terminal and make a request to the server using the curl commands.
Create an object with a POST request:
$ curl -X 'POST' \
'http://localhost:3000/heroes/' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"name": "my hero",
"secret_name": "austing",
"age": 12
}'
You should receive the following response:
{
"id": 1,
"name": "my hero",
"secret_name": "austing",
"age": 12
}
Now make a GET request:
$ curl http://localhost:3000/heroes/
You should receive the same response because it's the only object in the database.
Press ctrl+c in the terminal to stop your application.
Automatically update services
Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.
Open your compose.yaml file in an IDE or text editor and add the highlighted Compose Watch instructions.
{{< files name="nodejs-docker-example" >}}
{{< file path="compose.yaml" status="modified" hl_lines="21-27" >}}
services:
# Application service. The `target: dev` line builds the development
# image (includes tsx and dev tooling); the runner stage of the
# Dockerfile is unused in development.
server:
build:
context: .
target: dev
ports:
- 3000:3000
environment:
- POSTGRES_SERVER=db
- POSTGRES_USER=postgres
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
depends_on:
db:
condition: service_healthy
secrets:
- db-password
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: package.json
db:
image: dhi.io/postgres:18
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- 5432
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
{{< /file >}}
{{< /files >}}
Run the following command to run your application with Compose Watch.
$ docker compose watch
In a terminal, curl the application to get a response.
$ curl http://localhost:3000
{"message":"Hello World"}
Any changes to the application's source files on your local machine will now be immediately reflected in the running container.
Open nodejs-docker-example/src/index.ts in an IDE or text editor and update the Hello World string by adding a few more exclamation marks.
- res.json({ message: 'Hello World' });
+ res.json({ message: 'Hello World!!!' });
Save the changes to src/index.ts and then wait a few seconds for the application to reload. Curl the application again and verify that the updated text appears.
$ curl http://localhost:3000
{"message":"Hello World!!!"}
Press ctrl+c in the terminal to stop your application.
Debug your application
tsx watch supports the Node.js inspector protocol, so you can attach a debugger from VS Code or Chrome DevTools and set breakpoints directly in your TypeScript source files.
Update the dev script in package.json to start the inspector. The --inspect=0.0.0.0:9229 flag tells Node.js to listen for debugger connections on all network interfaces at port 9229. Using 0.0.0.0 rather than localhost is necessary so the debugger is reachable from outside the container. Also expose the debug port in compose.yaml, and add a .vscode/launch.json file that tells VS Code how to attach to the running inspector.
{{< files name="nodejs-docker-example" >}}
{{< file path="package.json" status="modified" hl_lines="9" >}}
{
"name": "nodejs-docker-example",
"version": "1.0.0",
"description": "A minimal Node.js TypeScript application.",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch --inspect=0.0.0.0:9229 src/index.ts"
},
"dependencies": {
"express": "^4.21.2",
"pg": "^8.16.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
}
{{< /file >}}
{{< file path=".vscode/launch.json" status="new" >}}
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Docker Container",
"type": "node",
"request": "attach",
"port": 9229,
"address": "localhost",
"localRoot": "${workspaceFolder}",
"remoteRoot": "/app",
"protocol": "inspector",
"restart": true,
"sourceMaps": true,
"skipFiles": ["<node_internals>/**"]
}
]
}
{{< /file >}}
{{< file path="compose.yaml" status="modified" hl_lines="11" >}}
services:
# Application service. The `target: dev` line builds the development
# image (includes tsx and dev tooling); the runner stage of the
# Dockerfile is unused in development.
server:
build:
context: .
target: dev
ports:
- 3000:3000
- 9229:9229
environment:
- POSTGRES_SERVER=db
- POSTGRES_USER=postgres
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
depends_on:
db:
condition: service_healthy
secrets:
- db-password
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: package.json
db:
image: dhi.io/postgres:18
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- 5432
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
{{< /file >}}
{{< /files >}}
Rebuild and restart with the updated configuration:
$ docker compose up --build
When the inspector is ready, you'll see a line like the following in the logs:
Debugger listening on ws://0.0.0.0:9229/...
VS Code
With .vscode/launch.json in place, attach the debugger using the Debug panel.
Open the Debug panel (Ctrl+Shift+D on Windows and Linux, Cmd+Shift+D on Mac), select Attach to Docker Container, and press F5. You can now set breakpoints in your TypeScript source files under src/.
Chrome DevTools
You can also use the built-in Node.js inspector in Chrome without any editor setup.
-
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.
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-osswith 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.jsonfor a global setupopencode.jsonin 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.
[!NOTE]
If your setup expects the older OpenAI-compatible path, use
http://localhost:12434/engines/v1instead.
Step 3: Verify the endpoint
Check that Docker Model Runner is reachable before you open OpenCode:
$ curl http://localhost:12434/v1/models
If you use the older path, run:
$ curl http://localhost:12434/engines/v1/models
The response should list the models available through Docker Model Runner.
Step 4: Start OpenCode
From your project directory, run:
$ opencode
To switch models from the TUI, run:
/models
Then select the model from the dmr provider.
Step 5: Package gpt-oss with a larger context window
This step is optional. Use it if you need a larger context window for repository-scale tasks.
gpt-oss defaults to a smaller context window than coding-focused models. If
you want to use it for repository-scale tasks, package a larger variant:
$ docker model pull ai/gpt-oss
$ docker model package --from ai/gpt-oss --context-size 128000 gpt-oss:128k
Then add it to your OpenCode configuration:
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"dmr": {
"npm": "@ai-sdk/openai-compatible",
"name": "Docker Model Runner",
"options": {
"baseURL": "http://localhost:12434/v1"
},
"models": {
"gpt-oss:128k": {
"name": "gpt-oss:128k"
}
}
}
}
}
Troubleshooting
If OpenCode can't connect, check Docker Model Runner status:
$ docker model status
If OpenCode does not show your model, list local models:
$ docker model ls
If the model is missing, pull it first and confirm the model name in
opencode.json matches the local model you want to use.
Learn more
Instrumenting a JavaScript App with OpenTelemetry
OpenTelemetry (OTel) is an open-source observability framework that provides a set of APIs, SDKs, and tools for collecting telemetry data, such as metrics, logs, and traces, from applications. With OpenTelemetry, developers can obtain valuable insights into how their services perform in production or during local development.
A key component of OpenTelemetry is the OpenTelemetry Protocol (OTLP) a general-purpose, vendor-agnostic protocol designed to transmit telemetry data efficiently and reliably. OTLP supports multiple data types (traces, metrics, logs) over HTTP or gRPC, making it the default and recommended protocol for communication between instrumented applications, the OpenTelemetry Collector, and backends such as Jaeger or Prometheus.
This guide walks you through how to instrument a simple Node.js application with OpenTelemetry and run both the app and a collector using Docker. This setup is ideal for local development and testing observability before integrating with external observability platforms like Prometheus, Jaeger, or Grafana.
In this guide, you'll learn how to:
- 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.yamlfile, add thepgadminservice next to your existingpostgresservice: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 upAfter 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
- Name:
- Connection tab:
- Host name/address:
postgres - Username:
postgres - Password:
secret - Enable the Save password? field
- Host name/address:
[!IMPORTANT]
These connection details assume you are using the previous Compose file snippet. If you are using an existing Compose file, adjust the connection details as required. The Host name/address field should match the name of your postgres service.
- General tab:
-
Select the Save button to create the new database.
You now have pgAdmin setup and connected to your containerized database. Feel free to navigate around, view the tables, and explore your database.
Configuring pgAdmin to auto-connect to the database
Although you have pgAdmin running, it would be nice if you could simply open the app without needing to configure the database connection. Reducing the setup steps would be a great way to make it easier for teammates to get value from this tool.
Fortunately, there is an ability to auto-connect to the database.
[!WARNING]
In order to auto-connect, the database credentials are shared using plaintext files. During local development, this is often acceptable as local data is not real customer data. However, if you are using production or sensitive data, this practice is strongly discouraged.
-
First, you need to define the server itself, which pgAdmin does using a
servers.jsonfile.Add the following to your
compose.yamlfile to define a config file for theservers.jsonfile:configs: pgadmin-servers: content: | { "Servers": { "1": { "Name": "Local Postgres", "Group": "Servers", "Host": "postgres", "Port": 5432, "MaintenanceDB": "postgres", "Username": "postgres", "PassFile": "/config/pgpass" } } } -
The
servers.jsonfile defines aPassFilefield, 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.yamlfile to define a pgpass file:configs: pgadmin-pgpass: content: | postgres:5432:*:postgres:secretThis will indicate any connection requests to
postgres:5432using the usernamepostgresshould provide a password ofsecret. -
In your
compose.yaml, update thepgadminservice 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 upagain:$ 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.
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored to your application.
Create the following files in your docker-php-sample directory.
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
################################################################################
# Create a stage for installing app dependencies defined in Composer.
FROM composer:lts as deps
WORKDIR /app
# If your composer.json file defines scripts that run during dependency installation and
# reference your application source files, uncomment the line below to copy all the files
# into this layer.
# COPY . .
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a bind mounts to composer.json and composer.lock to avoid having to copy them
# into this layer.
# Leverage a cache mount to /tmp/cache so that subsequent builds don't have to re-download packages.
RUN --mount=type=bind,source=composer.json,target=composer.json \
--mount=type=bind,source=composer.lock,target=composer.lock \
--mount=type=cache,target=/tmp/cache \
composer install --no-dev --no-interaction
################################################################################
# Create a new stage for running the application that contains the minimal
# runtime dependencies for the application. This often uses a different base
# image from the install or build stage where the necessary files are copied
# from the install stage.
#
# The example below uses the PHP Apache image as the foundation for running the app.
# By specifying the "8.2-apache" tag, it will also use whatever happens to be the
# most recent version of that tag when you build your Dockerfile.
# If reproducibility is important, consider using a specific digest SHA, like
# php@sha256:99cede493dfd88720b610eb8077c8688d3cca50003d76d1d539b0efc8cca72b4.
FROM php:8.2-apache as final
# Your PHP application may require additional PHP extensions to be installed
# manually. For detailed instructions for installing extensions can be found, see
# https://github.com/docker-library/docs/tree/master/php#how-to-install-more-php-extensions
# The following code blocks provide examples that you can edit and use.
#
# Add core PHP extensions, see
# https://github.com/docker-library/docs/tree/master/php#php-core-extensions
# This example adds the apt packages for the 'gd' extension's dependencies and then
# installs the 'gd' extension. For additional tips on running apt-get, see
# https://docs.docker.com/go/dockerfile-aptget-best-practices/
# RUN apt-get update && apt-get install -y \
# libfreetype-dev \
# libjpeg62-turbo-dev \
# libpng-dev \
# && rm -rf /var/lib/apt/lists/* \
# && docker-php-ext-configure gd --with-freetype --with-jpeg \
# && docker-php-ext-install -j$(nproc) gd
#
# Add PECL extensions, see
# https://github.com/docker-library/docs/tree/master/php#pecl-extensions
# This example adds the 'redis' and 'xdebug' extensions.
# RUN pecl install redis-5.3.7 \
# && pecl install xdebug-3.2.1 \
# && docker-php-ext-enable redis xdebug
# Use the default production configuration for PHP runtime arguments, see
# https://github.com/docker-library/docs/tree/master/php#configuration
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Copy the app dependencies from the previous install stage.
COPY --from=deps app/vendor/ /var/www/html/vendor
# Copy the app files from the app directory.
COPY ./src /var/www/html
# Switch to a non-privileged user (defined in the base image) that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
USER www-data
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
ports:
- 9000:80
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
# depends_on:
# db:
# condition: service_healthy
# db:
# image: postgres
# restart: always
# user: postgres
# secrets:
# - db-password
# volumes:
# - db-data:/var/lib/postgresql/data
# environment:
# - POSTGRES_DB=example
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
# expose:
# - 5432
# healthcheck:
# test: [ "CMD", "pg_isready" ]
# interval: 10s
# timeout: 5s
# retries: 5
# volumes:
# db-data:
# secrets:
# db-password:
# file: db/password.txt
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.y*ml
!**/composer.json
!**/composer.lock
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/vendor
LICENSE
README.md
You should now have the following contents in your docker-php-sample
directory.
├── docker-php-sample/
│ ├── .git/
│ ├── src/
│ ├── tests/
│ ├── .dockerignore
│ ├── .gitignore
│ ├── compose.yaml
│ ├── composer.json
│ ├── composer.lock
│ ├── Dockerfile
│ └── README.md
To learn more about these files, see the following:
Run the application
Inside the docker-php-sample directory, run the following command in a
terminal.
$ docker compose up --build
Open a browser and view the application at http://localhost:9000/hello.php. You should see a simple hello world application.
In the terminal, press ctrl+c to stop the application.
Run the application in the background
You can run the application detached from the terminal by adding the -d
option. Inside the docker-php-sample directory, run the following command
in a terminal.
$ docker compose up --build -d
Open a browser and view the application at http://localhost:9000/hello.php. You should see a simple hello world application.
In the terminal, run the following command to stop the application.
$ docker compose down
For more information about Compose commands, see the Compose CLI reference.
Use containers for PHP development
Prerequisites
Complete Containerize a PHP application.
Overview
In this section, you'll learn how to set up a development environment for your containerized application. This includes:
- 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
Dockerfileto install extensions to connect to the database - Update the
compose.yamlfile 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
[!NOTE]
To learn more about the instructions in the Compose file, see Compose file reference.
Before you run the application using Compose, notice that this Compose file uses
secrets and specifies a password.txt file to hold the database's password.
You must create this file as it's not included in the source repository.
In the docker-php-sample directory, create a new directory named db and
inside that directory create a file named password.txt. Open password.txt in an IDE or text editor and add the following password. The password must be on a single line, with no additional lines in the file.
example
Save and close the password.txt file.
You should now have the following in your docker-php-sample directory.
├── docker-php-sample/
│ ├── .git/
│ ├── db/
│ │ └── password.txt
│ ├── src/
│ ├── tests/
│ ├── .dockerignore
│ ├── .gitignore
│ ├── compose.yaml
│ ├── composer.json
│ ├── composer.lock
│ ├── Dockerfile
│ └── README.md
Run the following command to start your application.
$ docker compose up --build
Open a browser and view the application at http://localhost:9000/database.php. You should see a simple web application with text and a counter that increments every time you refresh.
Press ctrl+c in the terminal to stop your application.
Verify that data persists in the database
In the terminal, run docker compose rm to remove your containers and then run docker compose up to run your application again.
$ docker compose rm
$ docker compose up --build
Refresh http://localhost:9000/database.php in your browser and verify that the previous count still exists. Without a volume, the database data wouldn't persist after you remove the container.
Press ctrl+c in the terminal to stop your application.
Add phpMyAdmin to interact with the database
You can easily add services to your application stack by updating the compose.yaml file.
Update your compose.yaml to add a new service for phpMyAdmin. For more details, see the phpMyAdmin Official Docker Image. The following is the updated compose.yaml file.
services:
server:
build:
context: .
ports:
- 9000:80
depends_on:
db:
condition: service_healthy
secrets:
- db-password
environment:
- PASSWORD_FILE_PATH=/run/secrets/db-password
- DB_HOST=db
- DB_NAME=example
- DB_USER=root
db:
image: mariadb
restart: always
user: root
secrets:
- db-password
volumes:
- db-data:/var/lib/mysql
environment:
- MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-password
- MARIADB_DATABASE=example
expose:
- 3306
healthcheck:
test:
[
"CMD",
"/usr/local/bin/healthcheck.sh",
"--su-mysql",
"--connect",
"--innodb_initialized",
]
interval: 10s
timeout: 5s
retries: 5
phpmyadmin:
image: phpmyadmin
ports:
- 8080:80
depends_on:
- db
environment:
- PMA_HOST=db
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
In the terminal, run docker compose up to run your application again.
$ docker compose up --build
Open http://localhost:8080 in your browser to access phpMyAdmin. Log in using root as the username and example as the password. You can now interact with the database through phpMyAdmin.
Press ctrl+c in the terminal to stop your application.
Automatically update services
Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.
Open your compose.yaml file in an IDE or text editor and then add the Compose Watch instructions. The following is the updated compose.yaml file.
services:
server:
build:
context: .
ports:
- 9000:80
depends_on:
db:
condition: service_healthy
secrets:
- db-password
environment:
- PASSWORD_FILE_PATH=/run/secrets/db-password
- DB_HOST=db
- DB_NAME=example
- DB_USER=root
develop:
watch:
- action: sync
path: ./src
target: /var/www/html
db:
image: mariadb
restart: always
user: root
secrets:
- db-password
volumes:
- db-data:/var/lib/mysql
environment:
- MARIADB_ROOT_PASSWORD_FILE=/run/secrets/db-password
- MARIADB_DATABASE=example
expose:
- 3306
healthcheck:
test:
[
"CMD",
"/usr/local/bin/healthcheck.sh",
"--su-mysql",
"--connect",
"--innodb_initialized",
]
interval: 10s
timeout: 5s
retries: 5
phpmyadmin:
image: phpmyadmin
ports:
- 8080:80
depends_on:
- db
environment:
- PMA_HOST=db
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
Run the following command to run your application with Compose Watch.
$ docker compose watch
Open a browser and verify that the application is running at http://localhost:9000/hello.php.
Any changes to the application's source files on your local machine will now be immediately reflected in the running container.
Open hello.php in an IDE or text editor and update the string Hello, world! to Hello, Docker!.
Save the changes to hello.php and then wait a few seconds for the application to sync. Refresh http://localhost:9000/hello.php in your browser and verify that the updated text appears.
Press ctrl+c in the terminal to stop Compose Watch. Run docker compose down in the terminal to stop the application.
Create a development container
At this point, when you run your containerized application, Composer isn't installing the dev dependencies. While this small image is good for production, it lacks the tools and dependencies you may need when developing and it doesn't include the tests directory. You can use multi-stage builds to build stages for both development and production in the same Dockerfile. For more details, see Multi-stage builds.
In the Dockerfile, you'll need to update the following:
- Split the
depsstaged into two stages. One stage for production (prod-deps) and one stage (dev-deps) to install development dependencies. - Create a common
basestage. - Create a new
developmentstage for development. - Update the
finalstage to copy dependencies from the newprod-depsstage.
The following is the Dockerfile before and after the changes.
{{< tabs >}} {{< tab name="Before" >}}
# syntax=docker/dockerfile:1
FROM composer:lts as deps
WORKDIR /app
RUN --mount=type=bind,source=composer.json,target=composer.json \
--mount=type=bind,source=composer.lock,target=composer.lock \
--mount=type=cache,target=/tmp/cache \
composer install --no-dev --no-interaction
FROM php:8.2-apache as final
RUN docker-php-ext-install pdo pdo_mysql
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=deps app/vendor/ /var/www/html/vendor
COPY ./src /var/www/html
USER www-data
{{< /tab >}} {{< tab name="After" >}}
# syntax=docker/dockerfile:1
FROM composer:lts as prod-deps
WORKDIR /app
RUN --mount=type=bind,source=./composer.json,target=composer.json \
--mount=type=bind,source=./composer.lock,target=composer.lock \
--mount=type=cache,target=/tmp/cache \
composer install --no-dev --no-interaction
FROM composer:lts as dev-deps
WORKDIR /app
RUN --mount=type=bind,source=./composer.json,target=composer.json \
--mount=type=bind,source=./composer.lock,target=composer.lock \
--mount=type=cache,target=/tmp/cache \
composer install --no-interaction
FROM php:8.2-apache as base
RUN docker-php-ext-install pdo pdo_mysql
COPY ./src /var/www/html
FROM base as development
COPY ./tests /var/www/html/tests
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
COPY --from=dev-deps app/vendor/ /var/www/html/vendor
FROM base as final
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=prod-deps app/vendor/ /var/www/html/vendor
USER www-data
{{< /tab >}} {{< /tabs >}}
Update your compose.yaml file by adding an instruction to target the
development stage.
The following is the updated section of the compose.yaml file.
services:
server:
build:
context: .
target: development
# ...
Your containerized application will now install the dev dependencies.
Run the following command to start your application.
$ docker compose up --build
Open a browser and view the application at http://localhost:9000/hello.php. You should still see the simple "Hello, Docker!" application.
Press ctrl+c in the terminal to stop your application.
While the application appears the same, you can now make use of the dev dependencies. Continue to the next section to learn how you can run tests using Docker.
Run PHP tests in a container
Prerequisites
Complete all the previous sections of this guide, starting with Containerize a PHP application.
Overview
Testing is an essential part of modern software development. Testing can mean a lot of things to different development teams. There are unit tests, integration tests and end-to-end testing. In this guide you take a look at running your unit tests in Docker when developing and when building.
Run tests when developing locally
The sample application already has a PHPUnit test inside the tests directory. When developing locally, you can use Compose to run your tests.
Run the following command in the docker-php-sample directory to run the tests inside a container.
$ docker compose run --build --rm server ./vendor/bin/phpunit tests/HelloWorldTest.php
You should see output that contains the following.
Hello, Docker!PHPUnit 9.6.13 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.003, Memory: 4.00 MB
OK (1 test, 1 assertion)
To learn more about the command, see docker compose run.
Run tests when building
To run your tests when building, you need to update your Dockerfile. Create a new test stage that runs the tests.
The following is the updated Dockerfile.
# syntax=docker/dockerfile:1
FROM composer:lts as prod-deps
WORKDIR /app
RUN --mount=type=bind,source=./composer.json,target=composer.json \
--mount=type=bind,source=./composer.lock,target=composer.lock \
--mount=type=cache,target=/tmp/cache \
composer install --no-dev --no-interaction
FROM composer:lts as dev-deps
WORKDIR /app
RUN --mount=type=bind,source=./composer.json,target=composer.json \
--mount=type=bind,source=./composer.lock,target=composer.lock \
--mount=type=cache,target=/tmp/cache \
composer install --no-interaction
FROM php:8.2-apache as base
RUN docker-php-ext-install pdo pdo_mysql
COPY ./src /var/www/html
FROM base as development
COPY ./tests /var/www/html/tests
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
COPY --from=dev-deps app/vendor/ /var/www/html/vendor
FROM development as test
WORKDIR /var/www/html
RUN ./vendor/bin/phpunit tests/HelloWorldTest.php
FROM base as final
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --from=prod-deps app/vendor/ /var/www/html/vendor
USER www-data
Run the following command to build an image using the test stage as the target and view the test results. Include --progress plain to view the build output, --no-cache to ensure the tests always run, and --target test to target the test stage.
$ docker build -t php-docker-image-test --progress plain --no-cache --target test .
You should see output containing the following.
#18 [test 2/2] RUN ./vendor/bin/phpunit tests/HelloWorldTest.php
#18 0.385 Hello, Docker!PHPUnit 9.6.13 by Sebastian Bergmann and contributors.
#18 0.392
#18 0.394 . 1 / 1 (100%)
#18 0.395
#18 0.395 Time: 00:00.003, Memory: 4.00 MB
#18 0.395
#18 0.395 OK (1 test, 1 assertion)
Summary
In this section, you learned how to run tests when developing locally using Compose and how to run tests when building your image.
PostgreSQL specific guide
This guide gets you from zero to a running PostgreSQL container in under five minutes, then explains how to keep your data safe across container restarts and removals.
Overview
Running PostgreSQL in Docker requires understanding one critical concept: containers are ephemeral, but your data shouldn't be. This guide covers:
- 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)
[!NOTE]
Docker Hardened Images (DHIs) are minimal, secure, and production-ready container base and application images maintained by Docker. DHIs are recommended whenever it is possible for better security. They are designed to reduce vulnerabilities and simplify compliance, freely available to everyone with no subscription required, no usage restrictions, and no vendor lock-in.
Run PostgreSQL immediately with this single command:
{{< tabs >}} {{< tab name="Using DHIs" >}}
You must authenticate to dhi.io before you can pull Docker Hardened Images. Run docker login dhi.io to authenticate.
docker run --rm --name postgres-dev \
-e POSTGRES_PASSWORD=mysecretpassword \
-p 5432:5432 \
-d dhi.io/postgres:18
{{< /tab >}}
{{< tab name="Using DOIs" >}}
$ docker run --rm --name postgres-dev \
-e POSTGRES_PASSWORD=mysecretpassword \
-p 5432:5432 \
-d postgres:18
{{< /tab >}} {{< /tabs >}}
Understanding the flags
| Flag | Purpose |
|---|---|
--rm |
Automatically removes the container when it stops |
--name postgres-dev |
Assigns a memorable name instead of a random string |
-e POSTGRES_PASSWORD=... |
Sets the superuser password (required) |
-p 5432:5432 |
Maps host port 5432 to container port 5432 |
-d |
Runs the container in the background (detached mode) |
Verify the container is running:
$ docker ps --filter name=postgres-dev
CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
a1b2c3d4e5f6 postgres:18 "docker-entrypoint.s…" Up 2 seconds 0.0.0.0:5432->5432/tcp postgres-dev
Connect using psql from inside the container:
$ docker exec -it postgres-dev psql -U postgres
psql (18.0)
Type "help" for help.
postgres=#
You now have a working PostgreSQL instance. But there's a problem—stop this container and your data disappears.
The data persistence problem
Containers use an ephemeral filesystem. When a container is removed, everything inside it, including your database files, is deleted.
Demonstrate this yourself:
{{< tabs >}} {{< tab name="Using DHIs" >}}
$ docker exec postgres-dev psql -U postgres -c "CREATE DATABASE testdb;"
CREATE DATABASE
$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
testdb | postgres | UTF8 | libc | en_US.utf8 | en_US.utf8 | | |
$ docker stop postgres-dev
postgres-dev
$ docker run --rm --name postgres-dev \
-e POSTGRES_PASSWORD=mysecretpassword \
-p 5432:5432 \
-d dhi.io/postgres:18
$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
(no output - database is gone)
{{< /tab >}}
{{< tab name="Using DOIs" >}}
$ docker exec postgres-dev psql -U postgres -c "CREATE DATABASE testdb;"
CREATE DATABASE
$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
testdb | postgres | UTF8 | libc | en_US.utf8 | en_US.utf8 | | |
$ docker stop postgres-dev
postgres-dev
$ docker run --rm --name postgres-dev \
-e POSTGRES_PASSWORD=mysecretpassword \
-p 5432:5432 \
-d postgres:18
$ docker exec postgres-dev psql -U postgres -c "\l" | grep testdb
(no output - database is gone)
{{< /tab >}} {{< /tabs >}}
Your testdb database vanished because the new container started with a fresh filesystem. This is expected behavior—and exactly why volumes exist.
Named volumes
Named volumes are Docker-managed storage locations that persist independently of containers. Docker handles the filesystem location, permissions, and lifecycle.
Create a container with a named volume:
{{< tabs >}} {{< tab name="Using DHIs" >}}
You must authenticate to dhi.io before you can pull Docker Hardened Images. Run docker login dhi.io to authenticate.
$ docker run --rm --name postgres-dev \
-e POSTGRES_PASSWORD=mysecretpassword \
-p 5432:5432 \
-v postgres_data:/var/lib/postgresql \
-d dhi.io/postgres:18
{{< /tab >}}
{{< tab name="Using DOIs" >}}
$ docker run --rm --name postgres-dev \
-e POSTGRES_PASSWORD=mysecretpassword \
-p 5432:5432 \
-v postgres_data:/var/lib/postgresql \
-d postgres:18
{{< /tab >}} {{< /tabs >}}
The -v postgres_data:/var/lib/postgresql flag mounts a named volume called postgres_data to PostgreSQL's data directory. If the volume doesn't exist, Docker creates it automatically.
[!NOTE]
PostgreSQL 18+ stores data in a version-specific subdirectory under
/var/lib/postgresql. Mounting at this level (rather than/var/lib/postgresql/data) allows for easier upgrades usingpg_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 |
[!IMPORTANT]
Initialization scripts only run when the PostgreSQL data directory (
/var/lib/postgresql/data) is empty. If you mount a volume containing existing data, initialization is skipped. This behavior prevents overwriting existing databases.
Mounting initialization scripts
Use Docker Compose to mount initialization scripts into the container. First, create a project directory:
$ mkdir -p postgres-project/init-db
$ cd postgres-project
Create a compose.yaml file:
services:
db:
image: postgres:18
volumes:
- ./init-db:/docker-entrypoint-initdb.d
- postgres_data:/var/lib/postgresql
environment:
POSTGRES_PASSWORD: mysecretpassword
volumes:
postgres_data:
All scripts in the ./init-db directory execute when the container starts for the first time. This is great for bootstrapping databases.
Initialization script example
Create a file named init.sql in your init-db directory:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
This script runs automatically when the container starts for the first time, creating your initial database schema.
[!NOTE]
Ensure initialization scripts have proper read permissions. If you encounter "Permission denied" errors, run
chmod 644 init-db/*.sqlto make the files readable by the container.
Performance tuning
Default PostgreSQL settings are conservative to work on systems with limited resources. For production workloads, you should tune these parameters based on your container's allocated resources.
Method 1: Custom configuration file
For complete control, mount a custom postgresql.conf file. First, extract the default configuration:
$ docker run -i --rm postgres:18 cat /usr/share/postgresql/postgresql.conf.sample > my-postgres.conf
Edit my-postgres.conf with your desired settings, then mount it in your Compose file:
services:
db:
image: postgres:18
volumes:
- ./my-postgres.conf:/etc/postgresql/postgresql.conf
- ./init-db:/docker-entrypoint-initdb.d
- postgres_data:/var/lib/postgresql
command: postgres -c config_file=/etc/postgresql/postgresql.conf
environment:
POSTGRES_PASSWORD: mysecretpassword
volumes:
postgres_data:
Key configuration parameters
The following tables list important postgresql.conf parameters for containerized PostgreSQL deployments.
Connection settings
| Parameter | Description | Default |
|---|---|---|
listen_addresses |
IP addresses to listen on | localhost |
port |
TCP port number | 5432 |
max_connections |
Maximum concurrent connections | 100 |
Memory settings
| Parameter | Description | Recommended starting value |
|---|---|---|
shared_buffers |
Shared memory for caching | 25% of container memory |
work_mem |
Memory per query operation | 4MB - 64MB |
maintenance_work_mem |
Memory for VACUUM, CREATE INDEX | 64MB - 256MB |
effective_cache_size |
Planner's cache size estimate | 50-75% of container memory |
Docker memory limits
When tuning memory parameters, set explicit memory limits on your container using deploy.resources.limits.memory in Compose or --memory with docker run. Without limits, PostgreSQL sees the host's total RAM and may allocate more than intended. For example, if your container should use 4GB maximum, set shared_buffers to approximately 1GB (25%).
I/O settings
| Parameter | Description | Recommended starting value |
|---|---|---|
effective_io_concurrency |
Concurrent disk I/O operations | 200 for SSDs, 2 for HDDs |
Timeout settings
| Parameter | Description | Default |
|---|---|---|
statement_timeout |
Max time for any statement | 0 (disabled) |
lock_timeout |
Max time to wait for a lock | 0 (disabled) |
deadlock_timeout |
Time before checking for deadlock | 1s |
transaction_timeout |
Max time for a transaction | 0 (disabled) |
[!NOTE]
Setting
shared_bufferstoo 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
localhostand 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.
[!NOTE] Why not use the default bridge network? While containers on the default bridge network can communicate, they can only do so by IP address. Since container IP addresses change when containers restart, this would require updating your PostgreSQL connection strings each time. User-defined bridge networks solve this by providing automatic DNS resolution, ensuring your PostgreSQL connection strings remain stable even if containers restart and receive new IP addresses.
Here's a quick comparison:
[!NOTE]
The following examples show the difference in approach. To actually test this, follow the steps in this guide to set up containers on the appropriate networks first.
With the default bridge network, you'd need to find the IP address first:
# Get the container's IP address (changes on restart)
docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' postgres-dev
# Output: 172.17.0.2
# Then connect using that IP address from another container
# (No --network flag needed - containers default to bridge network)
docker run --rm -it \
-e PGPASSWORD=mysecretpassword \
postgres:18 \
psql -h 172.17.0.2 -U postgres
With a user-defined network, you simply use the container name:
# Container name works directly - no IP lookup needed
docker run --rm -it \
--network my-app-net \
-e PGPASSWORD=mysecretpassword \
postgres:18 \
psql -h postgres-dev -U postgres
Step 1: Create a user-defined network
docker network create my-app-net
# Example Output
ab7f984be43a0ca15534a9ee568716ddbe869a5875077fad3ef3192e3af7d288
docker network ls
# Output
ab7f984be43a my-app-net bridge local
Step 2: Run PostgreSQL on that network (no port publishing)
Notice there is no -p 5432:5432 here. This keeps PostgreSQL internal to Docker and not accessible from the host machine, which is more secure for production environments.
docker run -d --name postgres-dev \
--network my-app-net \
-e POSTGRES_PASSWORD=mysecretpassword \
-v postgres_data:/var/lib/postgresql \
postgres:18
# Output
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6d351ed89efc postgres:18 "docker-entrypoint.s…" 9 seconds ago Up 8 seconds 5432/tcp postgres-dev
Step 3: Connect from another container using the Postgres container name
You can test connectivity with a temporary psql client container:
docker run --rm -it \
--network my-app-net \
-e PGPASSWORD=mysecretpassword \
postgres:18 \
psql -h postgres-dev -U postgres
Key point: -h postgres-dev works because Docker DNS resolves the container name on a user-defined network. The container name acts as the hostname.
Connection string examples
When connecting from your application container, use these PostgreSQL connection strings:
-
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/postgresThis 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=postgresExample 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
postgresdatabase. If you created a custom database (e.g.,testdb), use:postgresql://postgres:mysecretpassword@postgres-dev:5432/testdbExample with SSL disabled (common in Docker networks): Add
?sslmode=disableto 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
[!NOTE]
The default port
5432is 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:
localhostor127.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
[!WARNING]
Exposing PostgreSQL to all network interfaces (
0.0.0.0:5432) makes it accessible from any device that can reach your host. Only use this in trusted network environments or behind a firewall. For production, consider using a reverse proxy or VPN instead.
PostgreSQL security considerations for external access
When exposing PostgreSQL to external access, follow these PostgreSQL-specific security practices:
- Avoid using the
postgressuperuser: The defaultpostgresuser 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
hardcodingpasswords. - 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
appservice connects to PostgreSQL using the service name (db) as the hostname in the connection string - PostgreSQL is accessible from your host at
localhost:5432for external tools - Both services are isolated on a custom network, providing network-level security
- The
depends_ondirective 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)
[!NOTE]
Docker Compose automatically creates a network for your project. Services can reach each other by service name without explicit network configuration, but defining a custom network gives you more control. For PostgreSQL, this means your application can always connect using the service name, regardless of container restarts or IP changes.
Troubleshooting
This section covers common PostgreSQL connection issues and their solutions when working with Docker networking.
"Could not translate host name postgres-dev"
- 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-devLook 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-devThis should show
5432/tcp -> 127.0.0.1:5432(or0.0.0.0:5432if 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_PASSWORDwhen 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_PASSWORDenvironment 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';
- Remove the volume:
- Try connecting with password prompt:
psql -h localhost -U postgres -W(the-Wflag 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/pgadmin4image - Networking: In a Docker Compose environment, pgAdmin connects to the database using the internal service name (for example,
db:5432) rather thanlocalhost
Docker Compose configuration
To quickly deploy pgAdmin:
pgadmin:
image: dpage/pgadmin4:8.14
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: secure_password
volumes:
- pgadmin_data:/var/lib/pgadmin
ports:
- "8080:80"
With this configuration, access the pgAdmin interface at http://localhost:8080. Use the email and password specified in the environment variables for initial sign in.
[!IMPORTANT]
In production environments, pass
PGADMIN_DEFAULT_PASSWORDas an external environment variable or use Docker secrets. Storing passwords in plain text withindocker-compose.ymlposes 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:
PgBouncerlistens on port 6432, avoiding confusion with the direct PostgreSQL connection on port 5432- The
depends_ondirective withservice_healthycondition ensures PgBouncer starts only after PostgreSQL is ready pool_mode = transactionis the optimal choice for most web applications- The Percona PgBouncer image requires mounted configuration files (without the
:roflag, as the entrypoint script needs to modify them) - This example uses
trustauthentication for simplicity. In production, configure proper SCRAM-SHA-256 authentication
[!NOTE]
The
Percona PgBouncerentrypoint 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:
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
5432onto the host to let a native-running application to connect to it with the passwordmysecretpassword.$ 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
psqlis the PostgreSQL interactive shell that is used to connect to a Postgres database and let you start executing SQL commands. Assuming that you already havepsqlutility 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 postgresYou can now execute any SQL queries or commands you need within the
psqlprompt.Use
\qor\quitto 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.sqland 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 auserstable. The table includes an auto-incrementingidas the primary key, anamefield with a maximum length of 50 characters, and a uniqueemailfield with up to 100 characters.After creating the table, the
INSERTcommand inserts three users into theuserstable 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.sqldirectly into the database by using the<operator. The command is used to execute a SQL script namedseed.sqlagainst a Postgres database namedsampledb.$ 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
psqlcommand to verify if the table named users is populated in the databasesampledbor not.$ docker exec -it postgres psql -h localhost -U postgres sampledbYou can now run
\lin thepsqlshell 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
\qor\quitto 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.sqlwith 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
Dockerfileand copy the following content.# syntax=docker/dockerfile:1 FROM postgres:18 COPY seed.sql /docker-entrypoint-initdb.d/This Dockerfile copies the
seed.sqlscript 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
dbusing the latest Postgres image, which sets up a database with the namesampledb, along with a userpostgresand a passwordmysecretpassword.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
5432on the host to the container's5432, letting you access the Postgres database from outside the container. It also definesdata_sqlfor 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.sqlfile in the same directory as the Dockerfile, execute the following command:$ docker compose up -d --build -
It’s time to verify if the table
usersget populated with the data.$ docker exec -it my_postgres_db psql -h localhost -U postgres sampledbsampledb=# 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
dotenvpackage which is used to load environment variables from an.envfile. The.config()method reads the.envfile and sets the environment variables as properties of theprocess.envobject. 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
seedDatafunction 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.jsYou should see the following command:
Database seeded successfully! -
Verify if the database is seeded correctly:
$ docker exec -it postgres psql -h localhost -U postgres sampledbsampledb=# 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
[!NOTE]
On Windows, activate the virtual environment with
.venv\Scripts\activateinstead ofsource .venv/bin/activate.
If you don't have Python installed, skip ahead to the next section. The remaining steps run the application in a container, with no local Python required.
Create the Docker assets
Sign in to the DHI registry so Docker can pull the Python base images during the build. The available Python images are listed in the catalog.
$ docker login dhi.io
Add the following three files to your python-docker-example directory. The
Dockerfile describes how to build the image, compose.yaml defines how
Docker runs the container, and .dockerignore keeps unwanted files out of the
build context.
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored to your application.
{{< files name="python-docker-example" >}}
{{< file path="app.py" >}}
# A minimal FastAPI application.
# The root endpoint (GET /) returns a JSON "Hello World" response.
# See https://fastapi.tiangolo.com/ for the framework reference.
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello World"}
{{< /file >}}
{{< file path="requirements.txt" >}}
# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/
fastapi==0.115.12
uvicorn==0.34.3
{{< /file >}}
{{< file path="Dockerfile" status="new" >}}
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/
# Use the dev image to build and install dependencies.
FROM dhi.io/python:3.12-dev AS builder
WORKDIR /app
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them into
# this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
pip install -r requirements.txt
# Use the minimal runtime image. It runs as nonroot by default.
FROM dhi.io/python:3.12
WORKDIR /app
COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"
# Copy the source code into the container.
COPY . .
# Expose the port that the application listens on.
EXPOSE 8000
# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]
{{< /file >}}
{{< file path="compose.yaml" status="new" >}}
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
ports:
- 8000:8000
{{< /file >}}
{{< file path=".dockerignore" status="new" >}}
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
{{< /file >}}
{{< file path=".gitignore" >}}
# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Secrets
db/password.txt
{{< /file >}}
{{< /files >}}
To learn more about each file, see the following:
Run the application
Inside the python-docker-example directory, run the following command in a
terminal.
$ docker compose up --build
Open a browser and view the application at http://localhost:8000. You should see a simple FastAPI application.
In the terminal, press ctrl+c to stop the application.
Run the application in the background
You can run the application detached from the terminal by adding the -d
option. Inside the python-docker-example directory, run the following command
in a terminal.
$ docker compose up --build -d
Open a browser and view the application at http://localhost:8000.
To see the OpenAPI docs you can go to http://localhost:8000/docs.
You should see a simple FastAPI application.
In the terminal, run the following command to stop the application.
$ docker compose down
For more information about Compose commands, see the Compose CLI reference.
Use containers for Python development
Prerequisites
Complete Containerize a Python application.
Overview
Once your application runs in a container, the next step is making the container loop part of your everyday development workflow. Code changes should show up quickly, and services your app depends on, like databases, should run right alongside it.
In this section, you'll extend the project from the previous topic by adding a
PostgreSQL database service to your compose.yaml, persisting the database
data in a named volume, and enabling Compose Watch so that changes you save in
your editor are picked up by the running container without a manual rebuild.
Update the application
You'll update your application to connect to a PostgreSQL database. Continue
working in your python-docker-example directory.
Replace app.py and requirements.txt, and add a new config.py file with the
following contents.
[!NOTE]
The application won't run yet after this step. It tries to connect to a PostgreSQL database that doesn't exist. The next two sections add the database service and the Docker configuration needed to run everything together.
{{< files name="python-docker-example" >}}
{{< file path="app.py" status="modified" >}}
# FastAPI application backed by a PostgreSQL database via SQLModel.
# The FastAPI lifespan handler creates database tables at startup.
# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list).
# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/
from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select
from config import settings
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: int | None = Field(default=None, index=True)
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
create_db_and_tables()
yield
app = FastAPI(lifespan=lifespan)
@app.get("/")
def hello() -> str:
return "Hello, Docker!"
@app.post("/heroes/")
def create_hero(hero: Hero) -> Hero:
with Session(engine) as session:
session.add(hero)
session.commit()
session.refresh(hero)
return hero
@app.get("/heroes/")
def read_heroes() -> Sequence[Hero]:
with Session(engine) as session:
heroes = session.exec(select(Hero)).all()
return heroes
{{< /file >}}
{{< file path="config.py" status="new" >}}
# Pydantic settings that read PostgreSQL connection details from the
# environment. Supports a password file (Docker secrets) via
# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD.
# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/
import os
from typing import Any
from pydantic import (
PostgresDsn,
computed_field,
field_validator,
model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
POSTGRES_SERVER: str
POSTGRES_PORT: int = 5432
POSTGRES_USER: str
POSTGRES_PASSWORD: str | None = None
POSTGRES_PASSWORD_FILE: str | None = None
POSTGRES_DB: str
@model_validator(mode="before")
@classmethod
def check_postgres_password(cls, data: Any) -> Any:
"""Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set."""
if isinstance(data, dict):
password_file: str | None = data.get("POSTGRES_PASSWORD_FILE") # type: ignore
password: str | None = data.get("POSTGRES_PASSWORD") # type: ignore
if password_file is None and password is None:
raise ValueError(
"At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set."
)
return data # type: ignore
@field_validator("POSTGRES_PASSWORD_FILE", mode="before")
@classmethod
def read_password_from_file(cls, v: str | None) -> str | None:
if v is not None:
file_path = v
if os.path.exists(file_path):
with open(file_path) as file:
return file.read().strip()
raise ValueError(f"Password file {file_path} does not exist.")
return v
@computed_field
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
url = MultiHostUrl.build(
scheme="postgresql+psycopg",
username=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD
if self.POSTGRES_PASSWORD
else self.POSTGRES_PASSWORD_FILE,
host=self.POSTGRES_SERVER,
port=self.POSTGRES_PORT,
path=self.POSTGRES_DB,
)
return PostgresDsn(url)
settings = Settings() # type: ignore
{{< /file >}}
{{< file path="requirements.txt" status="modified" hl_lines="5-7" >}}
# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/
fastapi==0.115.12
sqlmodel==0.0.24
psycopg[binary]==3.2.9
pydantic-settings==2.9.1
uvicorn==0.34.3
{{< /file >}}
{{< file path="Dockerfile" >}}
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/
# Use the dev image to build and install dependencies.
FROM dhi.io/python:3.12-dev AS builder
WORKDIR /app
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them into
# this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
pip install -r requirements.txt
# Use the minimal runtime image. It runs as nonroot by default.
FROM dhi.io/python:3.12
WORKDIR /app
COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"
# Copy the source code into the container.
COPY . .
# Expose the port that the application listens on.
EXPOSE 8000
# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]
{{< /file >}}
{{< file path="compose.yaml" >}}
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
ports:
- 8000:8000
{{< /file >}}
{{< file path=".dockerignore" >}}
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
{{< /file >}}
{{< file path=".gitignore" >}}
# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Secrets
db/password.txt
{{< /file >}}
{{< /files >}}
Update Docker assets
Replace Dockerfile and compose.yaml with the following.
{{< files name="python-docker-example" >}}
{{< file path="app.py" >}}
# FastAPI application backed by a PostgreSQL database via SQLModel.
# The FastAPI lifespan handler creates database tables at startup.
# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list).
# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/
from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select
from config import settings
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: int | None = Field(default=None, index=True)
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
create_db_and_tables()
yield
app = FastAPI(lifespan=lifespan)
@app.get("/")
def hello() -> str:
return "Hello, Docker!"
@app.post("/heroes/")
def create_hero(hero: Hero) -> Hero:
with Session(engine) as session:
session.add(hero)
session.commit()
session.refresh(hero)
return hero
@app.get("/heroes/")
def read_heroes() -> Sequence[Hero]:
with Session(engine) as session:
heroes = session.exec(select(Hero)).all()
return heroes
{{< /file >}}
{{< file path="config.py" >}}
# Pydantic settings that read PostgreSQL connection details from the
# environment. Supports a password file (Docker secrets) via
# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD.
# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/
import os
from typing import Any
from pydantic import (
PostgresDsn,
computed_field,
field_validator,
model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
POSTGRES_SERVER: str
POSTGRES_PORT: int = 5432
POSTGRES_USER: str
POSTGRES_PASSWORD: str | None = None
POSTGRES_PASSWORD_FILE: str | None = None
POSTGRES_DB: str
@model_validator(mode="before")
@classmethod
def check_postgres_password(cls, data: Any) -> Any:
"""Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set."""
if isinstance(data, dict):
password_file: str | None = data.get("POSTGRES_PASSWORD_FILE") # type: ignore
password: str | None = data.get("POSTGRES_PASSWORD") # type: ignore
if password_file is None and password is None:
raise ValueError(
"At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set."
)
return data # type: ignore
@field_validator("POSTGRES_PASSWORD_FILE", mode="before")
@classmethod
def read_password_from_file(cls, v: str | None) -> str | None:
if v is not None:
file_path = v
if os.path.exists(file_path):
with open(file_path) as file:
return file.read().strip()
raise ValueError(f"Password file {file_path} does not exist.")
return v
@computed_field
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
url = MultiHostUrl.build(
scheme="postgresql+psycopg",
username=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD
if self.POSTGRES_PASSWORD
else self.POSTGRES_PASSWORD_FILE,
host=self.POSTGRES_SERVER,
port=self.POSTGRES_PORT,
path=self.POSTGRES_DB,
)
return PostgresDsn(url)
settings = Settings() # type: ignore
{{< /file >}}
{{< file path="requirements.txt" >}}
# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/
fastapi==0.115.12
sqlmodel==0.0.24
psycopg[binary]==3.2.9
pydantic-settings==2.9.1
uvicorn==0.34.3
{{< /file >}}
{{< file path="Dockerfile" status="modified" hl_lines="11,27-34,37,45" >}}
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/
# Use the dev image to build and install dependencies.
# The builder stage is also used directly in development (see compose.yaml).
FROM dhi.io/python:3.12-dev AS builder
WORKDIR /app
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them
# into this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
pip install -r requirements.txt
# Copy the source code into the container.
COPY . .
# Expose the port that the application listens on.
EXPOSE 8000
# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]
# Use the minimal runtime image for production. It runs as nonroot by default.
FROM dhi.io/python:3.12
WORKDIR /app
COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"
COPY --from=builder /app .
EXPOSE 8000
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]
{{< /file >}}
{{< file path="compose.yaml" status="modified" hl_lines="8" >}}
services:
# Application service. The `target: builder` line builds the development
# image (includes a shell and tools); the production stage of the
# Dockerfile is unused in development.
server:
build:
context: .
target: builder
ports:
- 8000:8000
{{< /file >}}
{{< file path=".dockerignore" >}}
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
{{< /file >}}
{{< file path=".gitignore" >}}
# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Secrets
db/password.txt
{{< /file >}}
{{< /files >}}
About these changes
The Dockerfile builder stage now includes COPY . . and a CMD
instruction, which makes it directly runnable. This lets Compose target the
builder stage during development without rebuilding the production stage. The
production stage at the bottom is unchanged and still produces a minimal,
nonroot runtime image for shipping.
In compose.yaml, the new target: builder line tells Compose to build and
run the builder stage of the Dockerfile during development. Unlike the minimal
production image, the development image includes a shell and additional tools
that make debugging easier. If you need a shell in a running production
container, use Docker Debug instead.
Add a local database and persist data
You can use containers to set up local services, like a database. In this
section, you'll update the compose.yaml file to define a database service
and a volume to persist data, and add a db/password.txt file that holds the
database password.
{{< files name="python-docker-example" >}}
{{< file path="app.py" >}}
# FastAPI application backed by a PostgreSQL database via SQLModel.
# The FastAPI lifespan handler creates database tables at startup.
# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list).
# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/
from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select
from config import settings
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: int | None = Field(default=None, index=True)
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
create_db_and_tables()
yield
app = FastAPI(lifespan=lifespan)
@app.get("/")
def hello() -> str:
return "Hello, Docker!"
@app.post("/heroes/")
def create_hero(hero: Hero) -> Hero:
with Session(engine) as session:
session.add(hero)
session.commit()
session.refresh(hero)
return hero
@app.get("/heroes/")
def read_heroes() -> Sequence[Hero]:
with Session(engine) as session:
heroes = session.exec(select(Hero)).all()
return heroes
{{< /file >}}
{{< file path="config.py" >}}
# Pydantic settings that read PostgreSQL connection details from the
# environment. Supports a password file (Docker secrets) via
# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD.
# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/
import os
from typing import Any
from pydantic import (
PostgresDsn,
computed_field,
field_validator,
model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
POSTGRES_SERVER: str
POSTGRES_PORT: int = 5432
POSTGRES_USER: str
POSTGRES_PASSWORD: str | None = None
POSTGRES_PASSWORD_FILE: str | None = None
POSTGRES_DB: str
@model_validator(mode="before")
@classmethod
def check_postgres_password(cls, data: Any) -> Any:
"""Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set."""
if isinstance(data, dict):
password_file: str | None = data.get("POSTGRES_PASSWORD_FILE") # type: ignore
password: str | None = data.get("POSTGRES_PASSWORD") # type: ignore
if password_file is None and password is None:
raise ValueError(
"At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set."
)
return data # type: ignore
@field_validator("POSTGRES_PASSWORD_FILE", mode="before")
@classmethod
def read_password_from_file(cls, v: str | None) -> str | None:
if v is not None:
file_path = v
if os.path.exists(file_path):
with open(file_path) as file:
return file.read().strip()
raise ValueError(f"Password file {file_path} does not exist.")
return v
@computed_field
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
url = MultiHostUrl.build(
scheme="postgresql+psycopg",
username=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD
if self.POSTGRES_PASSWORD
else self.POSTGRES_PASSWORD_FILE,
host=self.POSTGRES_SERVER,
port=self.POSTGRES_PORT,
path=self.POSTGRES_DB,
)
return PostgresDsn(url)
settings = Settings() # type: ignore
{{< /file >}}
{{< file path="requirements.txt" >}}
# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/
fastapi==0.115.12
sqlmodel==0.0.24
psycopg[binary]==3.2.9
pydantic-settings==2.9.1
uvicorn==0.34.3
{{< /file >}}
{{< file path="Dockerfile" >}}
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/
# Use the dev image to build and install dependencies.
# The builder stage is also used directly in development (see compose.yaml).
FROM dhi.io/python:3.12-dev AS builder
WORKDIR /app
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them
# into this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
pip install -r requirements.txt
# Copy the source code into the container.
COPY . .
# Expose the port that the application listens on.
EXPOSE 8000
# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]
# Use the minimal runtime image for production. It runs as nonroot by default.
FROM dhi.io/python:3.12
WORKDIR /app
COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"
COPY --from=builder /app .
EXPOSE 8000
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]
{{< /file >}}
{{< file path="compose.yaml" status="modified" hl_lines="11-46" >}}
services:
# Application service. The `target: builder` line builds the development
# image (includes a shell and tools); the production stage of the
# Dockerfile is unused in development.
server:
build:
context: .
target: builder
ports:
- 8000:8000
environment:
- POSTGRES_SERVER=db
- POSTGRES_USER=postgres
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
depends_on:
db:
condition: service_healthy
secrets:
- db-password
# Database service. Reads the password from a Docker secret mounted at
# /run/secrets/db-password. Compose waits for the healthcheck to pass
# before starting the server, via the server's depends_on.
db:
image: dhi.io/postgres:18
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- 5432
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
{{< /file >}}
{{< file path="db/password.txt" status="new" >}}
mysecretpassword
{{< /file >}}
{{< file path=".dockerignore" >}}
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
{{< /file >}}
{{< file path=".gitignore" >}}
# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Secrets
db/password.txt
{{< /file >}}
{{< /files >}}
[!NOTE]
To learn more about the instructions in the Compose file, see Compose file reference.
Now, run the following docker compose up command to start your application.
$ docker compose up --build
Now test your API endpoint. Open a new terminal then make a request to the server using the curl commands:
Create an object with a POST request:
$ curl -X 'POST' \
'http://localhost:8000/heroes/' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"id": 1,
"name": "my hero",
"secret_name": "austing",
"age": 12
}'
You should receive the following response:
{
"age": 12,
"id": 1,
"name": "my hero",
"secret_name": "austing"
}
Now make a GET request:
$ curl -X 'GET' \
'http://localhost:8000/heroes/' \
-H 'accept: application/json'
You should receive the same response as above because it's the only object in the database.
{
"age": 12,
"id": 1,
"name": "my hero",
"secret_name": "austing"
}
Press ctrl+c in the terminal to stop your application.
Automatically update services
Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.
Open your compose.yaml file in an IDE or text editor and add the highlighted
Compose Watch instructions.
{{< files name="python-docker-example" >}}
{{< file path="app.py" >}}
# FastAPI application backed by a PostgreSQL database via SQLModel.
# The FastAPI lifespan handler creates database tables at startup.
# Endpoints: GET / (greeting), POST /heroes/ (create), GET /heroes/ (list).
# See https://fastapi.tiangolo.com/ and https://sqlmodel.tiangolo.com/
from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select
from config import settings
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: int | None = Field(default=None, index=True)
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
create_db_and_tables()
yield
app = FastAPI(lifespan=lifespan)
@app.get("/")
def hello() -> str:
return "Hello, Docker!"
@app.post("/heroes/")
def create_hero(hero: Hero) -> Hero:
with Session(engine) as session:
session.add(hero)
session.commit()
session.refresh(hero)
return hero
@app.get("/heroes/")
def read_heroes() -> Sequence[Hero]:
with Session(engine) as session:
heroes = session.exec(select(Hero)).all()
return heroes
{{< /file >}}
{{< file path="config.py" >}}
# Pydantic settings that read PostgreSQL connection details from the
# environment. Supports a password file (Docker secrets) via
# POSTGRES_PASSWORD_FILE in addition to POSTGRES_PASSWORD.
# See https://docs.pydantic.dev/latest/concepts/pydantic_settings/
import os
from typing import Any
from pydantic import (
PostgresDsn,
computed_field,
field_validator,
model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
POSTGRES_SERVER: str
POSTGRES_PORT: int = 5432
POSTGRES_USER: str
POSTGRES_PASSWORD: str | None = None
POSTGRES_PASSWORD_FILE: str | None = None
POSTGRES_DB: str
@model_validator(mode="before")
@classmethod
def check_postgres_password(cls, data: Any) -> Any:
"""Validate that either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is set."""
if isinstance(data, dict):
password_file: str | None = data.get("POSTGRES_PASSWORD_FILE") # type: ignore
password: str | None = data.get("POSTGRES_PASSWORD") # type: ignore
if password_file is None and password is None:
raise ValueError(
"At least one of POSTGRES_PASSWORD_FILE and POSTGRES_PASSWORD must be set."
)
return data # type: ignore
@field_validator("POSTGRES_PASSWORD_FILE", mode="before")
@classmethod
def read_password_from_file(cls, v: str | None) -> str | None:
if v is not None:
file_path = v
if os.path.exists(file_path):
with open(file_path) as file:
return file.read().strip()
raise ValueError(f"Password file {file_path} does not exist.")
return v
@computed_field
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
url = MultiHostUrl.build(
scheme="postgresql+psycopg",
username=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD
if self.POSTGRES_PASSWORD
else self.POSTGRES_PASSWORD_FILE,
host=self.POSTGRES_SERVER,
port=self.POSTGRES_PORT,
path=self.POSTGRES_DB,
)
return PostgresDsn(url)
settings = Settings() # type: ignore
{{< /file >}}
{{< file path="requirements.txt" >}}
# Python package dependencies for the application, pinned for reproducible builds.
# See https://pip.pypa.io/en/stable/reference/requirements-file-format/
fastapi==0.115.12
sqlmodel==0.0.24
psycopg[binary]==3.2.9
pydantic-settings==2.9.1
uvicorn==0.34.3
{{< /file >}}
{{< file path="Dockerfile" >}}
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/
# Use the dev image to build and install dependencies.
# The builder stage is also used directly in development (see compose.yaml).
FROM dhi.io/python:3.12-dev AS builder
WORKDIR /app
RUN python3 -m venv /venv
ENV PATH="/venv/bin:$PATH"
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them
# into this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
pip install -r requirements.txt
# Copy the source code into the container.
COPY . .
# Expose the port that the application listens on.
EXPOSE 8000
# Run the application.
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]
# Use the minimal runtime image for production. It runs as nonroot by default.
FROM dhi.io/python:3.12
WORKDIR /app
COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"
COPY --from=builder /app .
EXPOSE 8000
CMD ["/venv/bin/python3", "-m", "uvicorn", "app:app", "--host=0.0.0.0", "--port=8000"]
{{< /file >}}
{{< file path="compose.yaml" status="modified" hl_lines="21-24" >}}
services:
# Application service. The `target: builder` line builds the development
# image (includes a shell and tools); the production stage of the
# Dockerfile is unused in development.
server:
build:
context: .
target: builder
ports:
- 8000:8000
environment:
- POSTGRES_SERVER=db
- POSTGRES_USER=postgres
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
depends_on:
db:
condition: service_healthy
secrets:
- db-password
develop:
watch:
- action: rebuild
path: .
db:
image: dhi.io/postgres:18
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- 5432
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
{{< /file >}}
{{< file path="db/password.txt" >}}
mysecretpassword
{{< /file >}}
{{< file path=".dockerignore" >}}
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
{{< /file >}}
{{< file path=".gitignore" >}}
# Files and directories that Git should ignore. This is the standard Python
# template covering bytecode, build artifacts, virtual environments, and IDE
# settings. See https://git-scm.com/docs/gitignore for syntax reference.
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Secrets
db/password.txt
{{< /file >}}
{{< /files >}}
Run the following command to run your application with Compose Watch.
$ docker compose watch
In a terminal, curl the application to get a response.
$ curl http://localhost:8000
Hello, Docker!
Any changes to the application's source files on your local machine will now be immediately reflected in the running container.
Open python-docker-example/app.py in an IDE or text editor and update the Hello, Docker! string by adding a few more exclamation marks.
- return 'Hello, Docker!'
+ return 'Hello, Docker!!!'
Save the changes to app.py and then wait a few seconds for the application to rebuild. Curl the application again and verify that the updated text appears.
$ curl http://localhost:8000
Hello, Docker!!!
Press ctrl+c in the terminal to stop your application.
Linting, formatting, and type checking for Python
Prerequisites
Complete Develop your app. This topic requires a local Python installation because the tools and Git hooks introduced here run on your host. If you don't want to install Python locally, skip this topic. You can also run the same checks in CI.
Overview
Linting, formatting, and type checking are automated ways to catch bugs, enforce style, and spot type errors before code runs. Running them on every commit, in CI, and in your editor catches problems early when they're cheap to fix.
In this section, you'll configure three tools for your Python application. Ruff handles linting and formatting in a single fast pass. Pyright statically checks your code for type errors. Pre-commit hooks run both of these automatically before each Git commit so problems are caught locally before they're committed.
Linting and formatting with Ruff
Ruff is an extremely fast Python linter and formatter written in Rust. It replaces multiple tools like flake8, isort, and black with a single unified tool.
Create a pyproject.toml file in your python-docker-example directory:
{{< files name="python-docker-example" >}}
{{< file path="pyproject.toml" status="new" >}}
# Configuration for code-quality tools.
# - [tool.ruff]: linting and formatting (https://docs.astral.sh/ruff/)
# - [tool.pyright]: static type checking (https://microsoft.github.io/pyright/)
[tool.ruff]
target-version = "py312"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG001", # unused arguments in functions
]
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"W191", # indentation contains tabs
"B904", # Allow raising exceptions without from e, for HTTPException
]
{{< /file >}}
{{< /files >}}
Install Ruff:
$ pip install ruff
If you're using a virtual environment, make sure it is activated so the ruff
command is available.
Run these commands to check and format your code:
# Check for errors
$ ruff check .
# Automatically fix fixable errors
$ ruff check --fix .
# Format code
$ ruff format .
Type checking with Pyright
Pyright is a fast static type checker for Python that works well with modern Python features.
Update pyproject.toml to add the Pyright configuration at the bottom.
{{< files name="python-docker-example" >}}
{{< file path="pyproject.toml" status="modified" hl_lines="25-29" >}}
# Configuration for code-quality tools.
# - [tool.ruff]: linting and formatting (https://docs.astral.sh/ruff/)
# - [tool.pyright]: static type checking (https://microsoft.github.io/pyright/)
[tool.ruff]
target-version = "py312"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG001", # unused arguments in functions
]
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"W191", # indentation contains tabs
"B904", # Allow raising exceptions without from e, for HTTPException
]
[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"
exclude = [".venv"]
{{< /file >}}
{{< /files >}}
Install Pyright and run it:
$ pip install pyright
$ pyright
Setting up pre-commit hooks
Pre-commit hooks run checks automatically before each commit on your local
machine. Create a .pre-commit-config.yaml file in your python-docker-example
directory to set up Ruff hooks:
{{< files name="python-docker-example" >}}
{{< file path=".pre-commit-config.yaml" status="new" >}}
# Pre-commit hook configuration. Runs Ruff (lint + format) on every
# `git commit`. See https://pre-commit.com/
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.15
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
{{< /file >}}
{{< /files >}}
To install and use:
$ pip install pre-commit
$ pre-commit install
$ git commit -m "Test commit" # Automatically runs checks
Summary
In this section, you learned how to:
- 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.
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:
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
[!NOTE]
To learn more about the instructions in the Compose file, see Compose file reference.
Before you run the application using Compose, notice that this Compose file specifies a password.txt file to hold the database's password. You must create this file as it's not included in the source repository.
In the cloned repository's directory, create a new directory named db and inside that directory create a file named password.txt that contains the password for the database. Using your favorite IDE or text editor, add the following contents to the password.txt file.
mysecretpassword
Save and close the password.txt file.
You should now have the following contents in your r-docker-dev
directory.
├── r-docker-dev/
│ ├── db/
│ │ └── password.txt
│ ├── src/
│ │ └── app.R
│ ├── src_db/
│ │ └── app_db.R
│ ├── requirements.txt
│ ├── .dockerignore
│ ├── compose.yaml
│ ├── Dockerfile
│ └── README.md
Now, run the following docker compose up command to start your application.
$ docker compose up --build
Now test your DB connection opening a browser at:
http://localhost:3838
You should see a pop-up message:
DB CONNECTED
Press ctrl+c in the terminal to stop your application.
Automatically update services
Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.
Lines 15 to 18 in the compose.yaml file contain properties that trigger Docker
to rebuild the image when a file in the current working directory is changed:
services:
shiny-app:
build:
context: .
dockerfile: Dockerfile
ports:
- 3838:3838
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
depends_on:
db:
condition: service_healthy
secrets:
- db-password
develop:
watch:
- action: rebuild
path: .
db:
image: postgres:18
restart: always
user: postgres
secrets:
- db-password
volumes:
- db-data:/var/lib/postgresql
environment:
- POSTGRES_DB=example
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
expose:
- 5432
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
secrets:
db-password:
file: db/password.txt
Run the following command to run your application with Compose Watch.
$ docker compose watch
Now, if you modify your app.R you will see the changes in real time without re-building the image!
Press ctrl+c in the terminal to stop your application.
Summary
In this section, you took a look at setting up your Compose file to add a local database and persist data. You also learned how to use Compose Watch to automatically rebuild and run your container when you update your code.
React.js language-specific guide
The React.js language-specific guide shows you how to containerize a React.js application using Docker, following best practices for creating efficient, production-ready containers.
React.js is a widely used library for building interactive user interfaces. However, managing dependencies, environments, and deployments efficiently can be complex. Docker simplifies this process by providing a consistent and containerized environment.
Acknowledgment
Docker extends its sincere gratitude to Kristiyan Velkov for authoring this guide. As a Docker Captain and experienced Front-end engineer, his expertise in Docker, DevOps, and modern web development has made this resource invaluable for the community, helping developers navigate and optimize their Docker workflows.
What will you learn?
In this guide, you will learn how to:
- 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.
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored to your application.
Step 1: Create the Dockerfile
Before creating a Dockerfile, you need to choose a base image. You can either use the Node.js Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog.
Choosing DHI offers the advantage of a production-ready image that is lightweight and secure. For more information, see Docker Hardened Images.
[!IMPORTANT] This guide uses a stable Node.js LTS image tag that is considered secure when the guide is written. Because new releases and security patches are published regularly, the tag shown here may no longer be the safest option when you follow the guide. Always review the latest available image tags and select a secure, up-to-date version before building or deploying your application.
Official Node.js Docker Images: https://hub.docker.com/_/node
{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}} Docker Hardened Images (DHIs) are available for Node.js in the Docker Hardened Images catalog. Docker Hardened Images are freely available to everyone with no subscription required. You can pull and use them like any other Docker image after signing in to the DHI registry. For more information, see the DHI quickstart guide.
-
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;"]
[!NOTE] We are using nginx-unprivileged instead of the standard NGINX image to follow security best practices. Running as a non-root user in the final image:
- 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.
[!NOTE] This helps:
- Reduce image size
- Speed up the build process
- Prevent sensitive or unnecessary files (like
.env,.git, ornode_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:
[!NOTE] To learn more about configuring NGINX, see the official NGINX documentation.
worker_processes auto;
# Store PID in /tmp (always writable)
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Disable logging to avoid permission issues
access_log off;
error_log /dev/stderr warn;
# Optimize static file serving
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000;
# Gzip compression for optimized delivery
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 256;
gzip_vary on;
server {
listen 8080;
server_name localhost;
# Root directory where React.js build files are placed
root /usr/share/nginx/html;
index index.html;
# Serve React.js static files with proper caching
location / {
try_files $uri /index.html;
}
# Serve static assets with long cache expiration
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|map)$ {
expires 1y;
access_log off;
add_header Cache-Control "public, immutable";
}
# Handle React.js client-side routing
location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
Step 5: Build the React.js application image
With your custom configuration in place, you're now ready to build the Docker image for your React.js application.
The updated setup includes:
- 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.
[!NOTE] The
docker buildcommand 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
[!NOTE] For more information about Compose commands, see the Compose CLI reference.
Use containers for React.js development
Prerequisites
Complete Containerize React.js application.
Overview
In this section, you'll learn how to set up both production and development environments for your containerized React.js application using Docker Compose. This setup allows you to serve a static production build via Nginx and to develop efficiently inside containers using a live-reloading dev server with Compose Watch.
You’ll learn how to:
- 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-prodservice builds and serves your static production app using Nginx. - The
react-devservice runs your React development server with live reload and hot module replacement. watchtriggers file sync with Compose Watch.
[!NOTE] For more details, see the official guide: Use Compose Watch.
Step 3: Update vite.config.ts to ensure it works properly inside Docker
To make Vite’s development server work reliably inside Docker, you need to update your vite.config.ts with the correct settings.
Open the vite.config.ts file in your project root and update it as follows:
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "/",
plugins: [react()],
server: {
host: true,
port: 5173,
strictPort: true,
},
});
[!NOTE] The
serveroptions invite.config.tsare essential for running Vite inside Docker:
host: trueallows the dev server to be accessible from outside the container.port: 5173sets a consistent development port (must match the one exposed in Docker).strictPort: trueensures 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.tsxfile 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,
},
});
[!NOTE] The
testoptions invitest.config.tsare 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: trueenables global test functions likedescribe,it, andexpectwithout 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-testservice defined in yourcompose.yamlfile. - Execute the
npm run testscript using the same environment as development. - Automatically remove the container after the tests complete
docker compose run --rmcommand.
[!NOTE] For more information about Compose commands, see the Compose CLI reference.
Summary
In this section, you learned how to run unit tests for your React.js application inside a Docker container using Vitest and Docker Compose.
What you accomplished:
- Installed and configured Vitest and React Testing Library for testing React components.
- Created a
react-testservice incompose.yamlto isolate test execution. - Reused the development
Dockerfile.devto 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 runCLI 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:humbleThis guide uses the Humble distribution. You can replace
humblewith another supported distribution such asrolling,jazzy, oriron.[!NOTE]
This environment is temporary and does not maintain persistence. Any files you create or packages you install will be deleted once the container is stopped or removed.
-
Verify ROS 2 is working:
$ echo $ROS_DISTROYou 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 toolros-humble-rviz2- 3D visualization toolros-humble-rqt- Qt-based ROS graphical toolsros-humble-demo-nodes-cpp- C++ demo nodesros-humble-demo-nodes-py- Python demo nodesros-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-workspaceMoving forward, Linux users can use the
ws_linuxfolder, and macOS users can usews_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/bashFor macOS:
$ cd ws_mac $ docker compose up -d $ docker compose exec ros2 /bin/bashThis command builds the Docker image defined in your
Dockerfileand starts the container in the background.[!NOTE]
Building the image may take several minutes during the first run as the CLI pulls the base ROS 2 image and installs required dependencies. Subsequent starts will be significantly faster.
-
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 > Securityand enableAllow 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_keyThis 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+Cto 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 listYou 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/poseYou'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 listYou 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.pyThe 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
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
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored 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:
.dockerignorecompose.yamlDockerfilebin/docker-entrypoint
To learn more about the files, see the following:
2. Run the application
To run the application, run the following command in a terminal inside the application's directory.
$ RAILS_MASTER_KEY=<master_key_value> docker compose up --build
Open a browser and view the application at http://localhost:3000. You should see a simple Ruby on Rails application.
In the terminal, press ctrl+c to stop the application.
3. Run the application in the background
You can run the application detached from the terminal by adding the -d
option. Inside the docker-ruby-on-rails directory, run the following command
in a terminal.
$ docker compose up --build -d
Open a browser and view the application at http://localhost:3000.
You should see a simple Ruby on Rails application.
In the terminal, run the following command to stop the application.
$ docker compose down
For more information about Compose commands, see the Compose CLI reference.
Use containers for Ruby on Rails development
Prerequisites
Complete Containerize a Ruby on Rails application.
Overview
In this section, you'll learn how to set up a development environment for your containerized application. This includes:
- 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
[!NOTE]
To learn more about the instructions in the Compose file, see Compose file reference.
Before you run the application using Compose, notice that this Compose file specifies a password.txt file to hold the database's password. You must create this file as it's not included in the source repository.
In the cloned repository's directory, create a new directory named db and inside that directory create a file named password.txt that contains the password for the database. Using your favorite IDE or text editor, add the following contents to the password.txt file.
mysecretpassword
Save and close the password.txt file. In addition, in the file webapp.env you can change the password to connect to the database.
You should now have the following contents in your docker-ruby-on-rails
directory.
.
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── README.md
├── Rakefile
├── app/
├── bin/
├── compose.yaml
├── config/
├── config.ru
├── db/
│ ├── development.sqlite3
│ ├── migrate
│ ├── password.txt
│ ├── schema.rb
│ └── seeds.rb
├── lib/
├── log/
├── public/
├── storage/
├── test/
├── tmp/
└── vendor
Now, run the following docker compose up command to start your application.
$ docker compose up --build
In Ruby on Rails, db:migrate is a Rake task that is used to run migrations on the database. Migrations are a way to alter the structure of your database schema over time in a consistent and easy way.
$ docker exec -it docker-ruby-on-rails-web-1 rake db:migrate RAILS_ENV=test
You will see a similar message like this:
== 20240710193146 CreateWhales: migrating =====================================
-- create_table(:whales)
-> 0.0126s
== 20240710193146 CreateWhales: migrated (0.0127s) ============================
Refresh http://localhost:3000 in your browser and add the whales.
Press ctrl+c in the terminal to stop your application and run docker compose up again, the whales are being persisted.
Automatically update services
Use Compose Watch to automatically update your running Compose services as you edit and save your code. For more details about Compose Watch, see Use Compose Watch.
Open your compose.yaml file in an IDE or text editor and then add the Compose
Watch instructions. The following is the updated compose.yaml file.
services:
web:
build: .
command: bundle exec rails s -b '0.0.0.0'
ports:
- "3000:3000"
depends_on:
- db
environment:
- RAILS_ENV=test
env_file: "webapp.env"
develop:
watch:
- action: rebuild
path: .
db:
image: postgres:18
secrets:
- db-password
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db-password
volumes:
- postgres_data:/var/lib/postgresql
volumes:
postgres_data:
secrets:
db-password:
file: db/password.txt
Run the following command to run your application with Compose Watch.
$ docker compose watch
Any changes to the application's source files on your local machine will now be immediately reflected in the running container.
Open docker-ruby-on-rails/app/views/whales/index.html.erb in an IDE or text editor and update the Whales string by adding an exclamation mark.
- <h1>Whales</h1>
+ <h1>Whales!</h1>
Save the changes to index.html.erb and then wait a few seconds for the application to rebuild. Go to the application again and verify that the updated text appears.
Press ctrl+c in the terminal to stop your application.
Summary
In this section, you took a look at setting up your Compose file to add a local database and persist data. You also learned how to use Compose Watch to automatically rebuild and run your container when you update your code.
Rust language-specific guide
The Rust language-specific guide teaches you how to create a containerized Rust application using Docker. In this guide, you'll learn how to:
- 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
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored 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 amigrationsdirectory (in addition tosrc) 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 buildto build the image.$ docker build -t rust-backend-image . -
Run
docker runwith 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/usersYou 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.
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
WebApplicationFactoryto 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.
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Set up the project
Background
This guide builds on top of Microsoft's Integration tests in ASP.NET Core documentation. The original sample uses an in-memory SQLite database as the backing store for integration tests. You'll replace SQLite with a real Microsoft SQL Server instance running in a Docker container using Testcontainers.
You can find the original code sample in the dotnet/AspNetCore.Docs.Samples repository.
Clone the repository
Clone the Testcontainers guide repository and change into the project directory:
$ git clone https://github.com/testcontainers/tc-guide-testing-aspnet-core.git
$ cd tc-guide-testing-aspnet-core
Project structure
The solution contains two projects:
RazorPagesProject.sln
├── src/RazorPagesProject/ # ASP.NET Core Razor Pages app
└── tests/RazorPagesProject.Tests/ # xUnit integration tests
Application project
The application project (src/RazorPagesProject/RazorPagesProject.csproj)
is a Razor Pages web app that uses Entity Framework Core with SQLite as its
default database provider:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="7.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
The ApplicationDbContext stores Message entities and provides methods to
query and manage them:
public class ApplicationDbContext : IdentityDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public virtual DbSet<Message> Messages { get; set; }
public async virtual Task<List<Message>> GetMessagesAsync()
{
return await Messages
.OrderBy(message => message.Text)
.AsNoTracking()
.ToListAsync();
}
public async virtual Task AddMessageAsync(Message message)
{
await Messages.AddAsync(message);
await SaveChangesAsync();
}
public async virtual Task DeleteAllMessagesAsync()
{
foreach (Message message in Messages)
{
Messages.Remove(message);
}
await SaveChangesAsync();
}
public async virtual Task DeleteMessageAsync(int id)
{
var message = await Messages.FindAsync(id);
if (message != null)
{
Messages.Remove(message);
await SaveChangesAsync();
}
}
public void Initialize()
{
Messages.AddRange(GetSeedingMessages());
SaveChanges();
}
public static List<Message> GetSeedingMessages()
{
return new List<Message>()
{
new Message(){ Text = "You're standing on my scarf." },
new Message(){ Text = "Would you like a jelly baby?" },
new Message(){ Text = "To the rational mind, nothing is inexplicable; only unexplained." }
};
}
}
Test project
The test project (tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj)
includes xUnit, the ASP.NET Core testing infrastructure, and the
Testcontainers MSSQL module:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="0.17.1" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.0" />
<PackageReference Include="Testcontainers.MsSql" Version="3.0.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\RazorPagesProject\RazorPagesProject.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="xunit.runner.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
The key dependencies are:
Microsoft.AspNetCore.Mvc.Testing- providesWebApplicationFactoryfor bootstrapping the app in testsMicrosoft.EntityFrameworkCore.SqlServer- the SQL Server database provider for Entity Framework CoreTestcontainers.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
[!NOTE] Testcontainers for .NET offers a range of modules that follow best practice configurations.
Create the test class
Create a MsSqlTests.cs file in the IntegrationTests directory. This class
manages the SQL Server container lifecycle and contains a nested test class.
using System.Data.Common;
using System.Net;
using AngleSharp.Html.Dom;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using RazorPagesProject.Data;
using RazorPagesProject.Tests.Helpers;
using Testcontainers.MsSql;
using Xunit;
namespace RazorPagesProject.Tests.IntegrationTests;
public sealed class MsSqlTests : IAsyncLifetime
{
private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder().Build();
public Task InitializeAsync()
{
return _msSqlContainer.StartAsync();
}
public Task DisposeAsync()
{
return _msSqlContainer.DisposeAsync().AsTask();
}
public sealed class IndexPageTests : IClassFixture<MsSqlTests>, IDisposable
{
private readonly WebApplicationFactory<Program> _webApplicationFactory;
private readonly HttpClient _httpClient;
public IndexPageTests(MsSqlTests fixture)
{
var clientOptions = new WebApplicationFactoryClientOptions();
clientOptions.AllowAutoRedirect = false;
_webApplicationFactory = new CustomWebApplicationFactory(fixture);
_httpClient = _webApplicationFactory.CreateClient(clientOptions);
}
public void Dispose()
{
_webApplicationFactory.Dispose();
}
[Fact]
public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot()
{
// Arrange
var defaultPage = await _httpClient.GetAsync("/")
.ConfigureAwait(false);
var document = await HtmlHelpers.GetDocumentAsync(defaultPage)
.ConfigureAwait(false);
// Act
var form = (IHtmlFormElement)document.QuerySelector("form[id='messages']");
var submitButton = (IHtmlButtonElement)document.QuerySelector("button[id='deleteAllBtn']");
var response = await _httpClient.SendAsync(form, submitButton)
.ConfigureAwait(false);
// Assert
Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("/", response.Headers.Location.OriginalString);
}
private sealed class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _connectionString;
public CustomWebApplicationFactory(MsSqlTests fixture)
{
_connectionString = fixture._msSqlContainer.GetConnectionString();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.Remove(services.SingleOrDefault(service => typeof(DbContextOptions<ApplicationDbContext>) == service.ServiceType));
services.Remove(services.SingleOrDefault(service => typeof(DbConnection) == service.ServiceType));
services.AddDbContext<ApplicationDbContext>((_, option) => option.UseSqlServer(_connectionString));
});
}
}
}
}
Understand the test structure
Container lifecycle with IAsyncLifetime
The outer MsSqlTests class implements IAsyncLifetime. xUnit calls
InitializeAsync() right after creating the class instance, which starts the
SQL Server container. After all tests complete, DisposeAsync() stops and
removes the container.
private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder().Build();
MsSqlBuilder().Build() creates a pre-configured Microsoft SQL Server
container. Testcontainers modules follow best practices, so you don't need
to configure ports, passwords, or startup wait strategies yourself.
Nested test class with IClassFixture
The IndexPageTests class is nested inside MsSqlTests and implements
IClassFixture<MsSqlTests>. This gives the test class access to the
container's private field and creates a clean hierarchy in the test explorer.
Custom WebApplicationFactory
Instead of using the SQLite-based factory, the nested
CustomWebApplicationFactory retrieves the connection string from the running
SQL Server container and passes it to UseSqlServer():
private sealed class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _connectionString;
public CustomWebApplicationFactory(MsSqlTests fixture)
{
_connectionString = fixture._msSqlContainer.GetConnectionString();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.Remove(services.SingleOrDefault(service => typeof(DbContextOptions<ApplicationDbContext>) == service.ServiceType));
services.Remove(services.SingleOrDefault(service => typeof(DbConnection) == service.ServiceType));
services.AddDbContext<ApplicationDbContext>((_, option) => option.UseSqlServer(_connectionString));
});
}
}
This factory:
- Removes the existing
DbContextOptions<ApplicationDbContext>registration - Removes the existing
DbConnectionregistration - Adds a new
ApplicationDbContextconfigured with the SQL Server connection string from the Testcontainers-managed container
[!NOTE] The Microsoft SQL Server Docker image isn't compatible with ARM devices, such as Macs with Apple Silicon. You can use the SqlEdge module or Testcontainers Cloud as alternatives.
Run tests and next steps
Run the tests
Run the tests from the solution root:
$ dotnet test ./RazorPagesProject.sln
The first run may take longer because Docker needs to pull the Microsoft SQL Server image. On subsequent runs, the image is cached locally.
You should see xUnit discover and run the tests, including the
MsSqlTests.IndexPageTests class. Testcontainers starts a SQL Server
container, the tests execute against it, and the container is stopped and
removed automatically after the tests finish.
Summary
By replacing SQLite with a Testcontainers-managed Microsoft SQL Server instance, the integration tests run against the same type of database used in production. This approach catches database-specific issues early, such as differences in SQL dialect, transaction behavior, or data type handling between SQLite and SQL Server.
The MsSqlTests class uses IAsyncLifetime to manage the container lifecycle,
and a nested CustomWebApplicationFactory wires the container's connection
string into the application's service configuration. You can apply this same
pattern to any database or service that Testcontainers supports.
To learn more about Testcontainers, visit the Testcontainers overview.
Further reading
- 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
CustomerServicethat 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
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Create the .NET project
Set up the solution
Create a .NET solution with source and test projects:
$ dotnet new sln -o TestcontainersDemo
$ cd TestcontainersDemo
$ dotnet new classlib -o CustomerService
$ dotnet sln add ./CustomerService/CustomerService.csproj
$ dotnet new xunit -o CustomerService.Tests
$ dotnet sln add ./CustomerService.Tests/CustomerService.Tests.csproj
$ dotnet add ./CustomerService.Tests/CustomerService.Tests.csproj reference ./CustomerService/CustomerService.csproj
Add the Npgsql dependency to the source project:
$ dotnet add ./CustomerService/CustomerService.csproj package Npgsql
Implement the business logic
Create a Customer record type:
namespace Customers;
public readonly record struct Customer(long Id, string Name);
Create a DbConnectionProvider class to manage database connections:
using System.Data.Common;
using Npgsql;
namespace Customers;
public sealed class DbConnectionProvider
{
private readonly string _connectionString;
public DbConnectionProvider(string connectionString)
{
_connectionString = connectionString;
}
public DbConnection GetConnection()
{
return new NpgsqlConnection(_connectionString);
}
}
Create the CustomerService class:
namespace Customers;
public sealed class CustomerService
{
private readonly DbConnectionProvider _dbConnectionProvider;
public CustomerService(DbConnectionProvider dbConnectionProvider)
{
_dbConnectionProvider = dbConnectionProvider;
CreateCustomersTable();
}
public IEnumerable<Customer> GetCustomers()
{
IList<Customer> customers = new List<Customer>();
using var connection = _dbConnectionProvider.GetConnection();
using var command = connection.CreateCommand();
command.CommandText = "SELECT id, name FROM customers";
command.Connection?.Open();
using var dataReader = command.ExecuteReader();
while (dataReader.Read())
{
var id = dataReader.GetInt64(0);
var name = dataReader.GetString(1);
customers.Add(new Customer(id, name));
}
return customers;
}
public void Create(Customer customer)
{
using var connection = _dbConnectionProvider.GetConnection();
using var command = connection.CreateCommand();
var id = command.CreateParameter();
id.ParameterName = "@id";
id.Value = customer.Id;
var name = command.CreateParameter();
name.ParameterName = "@name";
name.Value = customer.Name;
command.CommandText = "INSERT INTO customers (id, name) VALUES(@id, @name)";
command.Parameters.Add(id);
command.Parameters.Add(name);
command.Connection?.Open();
command.ExecuteNonQuery();
}
private void CreateCustomersTable()
{
using var connection = _dbConnectionProvider.GetConnection();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE IF NOT EXISTS customers (id BIGINT NOT NULL, name VARCHAR NOT NULL, PRIMARY KEY (id))";
command.Connection?.Open();
command.ExecuteNonQuery();
}
}
Here's what CustomerService does:
- The constructor calls
CreateCustomersTable()to ensure the table exists. GetCustomers()fetches all rows from thecustomerstable and returns them asCustomerobjects.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
PostgreSqlContainerusing thePostgreSqlBuilderwith thepostgres:16-alpineDocker image. - Implements
IAsyncLifetimefor container lifecycle management:InitializeAsync()starts the container before the test runs.DisposeAsync()stops and removes the container after the test finishes.
ShouldReturnTwoCustomers()creates aCustomerServicewith 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
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.
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Create the Go project
Initialize the project
Start by creating a Go project.
$ mkdir testcontainers-go-demo
$ cd testcontainers-go-demo
$ go mod init github.com/testcontainers/testcontainers-go-demo
This guide uses the jackc/pgx PostgreSQL driver to interact with the Postgres database and the testcontainers-go Postgres module to spin up a Postgres Docker instance for testing. It also uses testify for running multiple tests as a suite and for writing assertions.
Install these dependencies:
$ go get github.com/jackc/pgx/v5
$ go get github.com/testcontainers/testcontainers-go
$ go get github.com/testcontainers/testcontainers-go/modules/postgres
$ go get github.com/stretchr/testify
Create Customer struct
Create a types.go file in the customer package and define the Customer
struct to model the customer details:
package customer
type Customer struct {
Id int
Name string
Email string
}
Create Repository
Next, create customer/repo.go, define the Repository struct, and add
methods to create a customer and get a customer by email:
package customer
import (
"context"
"fmt"
"os"
"github.com/jackc/pgx/v5"
)
type Repository struct {
conn *pgx.Conn
}
func NewRepository(ctx context.Context, connStr string) (*Repository, error) {
conn, err := pgx.Connect(ctx, connStr)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
return nil, err
}
return &Repository{
conn: conn,
}, nil
}
func (r Repository) CreateCustomer(ctx context.Context, customer Customer) (Customer, error) {
err := r.conn.QueryRow(ctx,
"INSERT INTO customers (name, email) VALUES ($1, $2) RETURNING id",
customer.Name, customer.Email).Scan(&customer.Id)
return customer, err
}
func (r Repository) GetCustomerByEmail(ctx context.Context, email string) (Customer, error) {
var customer Customer
query := "SELECT id, name, email FROM customers WHERE email = $1"
err := r.conn.QueryRow(ctx, query, email).
Scan(&customer.Id, &customer.Name, &customer.Email)
if err != nil {
return Customer{}, err
}
return customer, nil
}
Here's what the code does:
Repositoryholds a*pgx.Connfor performing database operations.NewRepository(connStr)takes a database connection string and initializes aRepository.CreateCustomer()andGetCustomerByEmail()are methods on theRepositoryreceiver 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 thepostgres:16-alpineDocker 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 theCUSTOMERStable 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 afterpostgres.Run(). This registers automatic cleanup with the test framework, replacing the manualt.CleanupandTerminatepattern. - Obtains the database
ConnectionStringfrom the container and initializes aRepository. - Creates a customer with the email
henry@gmail.comand 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:
CustomerRepoTestSuiteextendssuite.Suiteand includes fields shared across multiple tests.SetupSuite()runs once before all tests. It callsCreatePostgresContainer(suite.T(), ...)which handles cleanup registration automatically viaCleanupContainer, so noTearDownSuite()is needed.TestCreateCustomer()usesrequire.NoError()for the create operation (fail immediately if it errors) andassert.NotNil()for the ID check.TestGetCustomerByEmail()usesrequire.NoError()then asserts on the returned values.TestCustomerRepoTestSuite(t *testing.T)runs the test suite when you executego test.
[!TIP] For the purpose of this guide, the tests don't reset data in the database. In practice, it's a good idea to reset the database to a known state before running each test.
Run tests and next steps
Run the tests
Run all the tests using go test ./.... Optionally add the -v flag for
verbose output:
$ go test -v ./...
You should see two Postgres Docker containers start automatically: one for the suite and its two tests, and another for the initial standalone test. All tests should pass. After the tests finish, the containers are stopped and removed automatically.
Summary
The Testcontainers for Go library helps you write integration tests by using the same type of database (Postgres) that you use in production, instead of mocks. Because you aren't using mocks and instead talk to real services, you're free to refactor code and still verify that the application works as expected.
To learn more about Testcontainers, visit the Testcontainers overview.
Further reading
- 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
CustomerServicethat 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
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Create the Java project
Set up the Maven project
Create a Java project with Maven from your preferred IDE. This guide uses
Maven, but you can use Gradle if you prefer. Add the following dependencies
to pom.xml:
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
This adds the Postgres JDBC driver, logback for logging, JUnit 5 for testing,
and the latest maven-surefire-plugin for JUnit 5 support.
Implement the business logic
Create a Customer record:
package com.testcontainers.demo;
public record Customer(Long id, String name) {}
Create a DBConnectionProvider class to hold JDBC connection parameters and
provide a database Connection:
package com.testcontainers.demo;
import java.sql.Connection;
import java.sql.DriverManager;
class DBConnectionProvider {
private final String url;
private final String username;
private final String password;
public DBConnectionProvider(String url, String username, String password) {
this.url = url;
this.username = username;
this.password = password;
}
Connection getConnection() {
try {
return DriverManager.getConnection(url, username, password);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
Create the CustomerService class:
package com.testcontainers.demo;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
public class CustomerService {
private final DBConnectionProvider connectionProvider;
public CustomerService(DBConnectionProvider connectionProvider) {
this.connectionProvider = connectionProvider;
createCustomersTableIfNotExists();
}
public void createCustomer(Customer customer) {
try (Connection conn = this.connectionProvider.getConnection()) {
PreparedStatement pstmt = conn.prepareStatement(
"insert into customers(id,name) values(?,?)"
);
pstmt.setLong(1, customer.id());
pstmt.setString(2, customer.name());
pstmt.execute();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public List<Customer> getAllCustomers() {
List<Customer> customers = new ArrayList<>();
try (Connection conn = this.connectionProvider.getConnection()) {
PreparedStatement pstmt = conn.prepareStatement(
"select id,name from customers"
);
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
long id = rs.getLong("id");
String name = rs.getString("name");
customers.add(new Customer(id, name));
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return customers;
}
private void createCustomersTableIfNotExists() {
try (Connection conn = this.connectionProvider.getConnection()) {
PreparedStatement pstmt = conn.prepareStatement(
"""
create table if not exists customers (
id bigint not null,
name varchar not null,
primary key (id)
)
"""
);
pstmt.execute();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
Here's what CustomerService does:
- The constructor calls
createCustomersTableIfNotExists()to ensure the table exists. createCustomer()inserts a customer record into the database.getAllCustomers()fetches all rows from thecustomerstable and returns a list ofCustomerobjects.
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
PostgreSQLContainerwith thepostgres:16-alpineDocker image. - The
@BeforeAllcallback starts the Postgres container before any test methods run. - The
@BeforeEachcallback creates aDBConnectionProviderusing the JDBC connection parameters from the container, then creates aCustomerService. TheCustomerServiceconstructor creates thecustomerstable if it doesn't exist. shouldGetCustomers()inserts 2 customer records, fetches all customers, and asserts the count.- The
@AfterAllcallback 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 (
@Testcontainersand@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
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Create the project and business logic
Set up the project
Create a Java project with Maven and add the required dependencies:
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>2.0.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<version>2.0.4</version>
<scope>test</scope>
</dependency>
</dependencies>
Create the business logic
Create a Customer record:
package com.testcontainers.demo;
public record Customer(Long id, String name) {}
Create a CustomerService class with methods to create, retrieve, and delete
customers:
package com.testcontainers.demo;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class CustomerService {
private final String url;
private final String username;
private final String password;
public CustomerService(String url, String username, String password) {
this.url = url;
this.username = username;
this.password = password;
createCustomersTableIfNotExists();
}
public void createCustomer(Customer customer) {
try (Connection conn = this.getConnection()) {
PreparedStatement pstmt = conn.prepareStatement(
"insert into customers(id,name) values(?,?)"
);
pstmt.setLong(1, customer.id());
pstmt.setString(2, customer.name());
pstmt.execute();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public List<Customer> getAllCustomers() {
List<Customer> customers = new ArrayList<>();
try (Connection conn = this.getConnection()) {
PreparedStatement pstmt = conn.prepareStatement(
"select id,name from customers"
);
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
long id = rs.getLong("id");
String name = rs.getString("name");
customers.add(new Customer(id, name));
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return customers;
}
public Optional<Customer> getCustomer(Long customerId) {
try (Connection conn = this.getConnection()) {
PreparedStatement pstmt = conn.prepareStatement(
"select id,name from customers where id = ?"
);
pstmt.setLong(1, customerId);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
long id = rs.getLong("id");
String name = rs.getString("name");
return Optional.of(new Customer(id, name));
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return Optional.empty();
}
public void deleteAllCustomers() {
try (Connection conn = this.getConnection()) {
PreparedStatement pstmt = conn.prepareStatement("delete from customers");
pstmt.execute();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private void createCustomersTableIfNotExists() {
try (Connection conn = this.getConnection()) {
PreparedStatement pstmt = conn.prepareStatement(
"""
create table if not exists customers (
id bigint not null,
name varchar not null,
primary key (id)
)
"""
);
pstmt.execute();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private Connection getConnection() {
try {
return DriverManager.getConnection(url, username, password);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
JUnit 5 lifecycle callbacks
When testing with Testcontainers, you want to start the required containers
before executing any tests and remove them afterwards. You can use JUnit 5
@BeforeAll and @AfterAll lifecycle callback methods for this:
package com.testcontainers.demo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.postgresql.PostgreSQLContainer;
class CustomerServiceWithLifeCycleCallbacksTest {
static PostgreSQLContainer postgres = new PostgreSQLContainer(
"postgres:16-alpine"
);
CustomerService customerService;
@BeforeAll
static void startContainers() {
postgres.start();
}
@AfterAll
static void stopContainers() {
postgres.stop();
}
@BeforeEach
void setUp() {
customerService =
new CustomerService(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword()
);
customerService.deleteAllCustomers();
}
@Test
void shouldCreateCustomer() {
customerService.createCustomer(new Customer(1L, "George"));
Optional<Customer> customer = customerService.getCustomer(1L);
assertTrue(customer.isPresent());
assertEquals(1L, customer.get().id());
assertEquals("George", customer.get().name());
}
@Test
void shouldGetCustomers() {
customerService.createCustomer(new Customer(1L, "George"));
customerService.createCustomer(new Customer(2L, "John"));
List<Customer> customers = customerService.getAllCustomers();
assertEquals(2, customers.size());
}
}
Here's what the code does:
PostgreSQLContaineris declared as a static field. The container starts before all tests and stops after all tests in this class.@BeforeAllstarts the container,@AfterAllstops it.@BeforeEachinitializesCustomerServicewith 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/@AfterEachto 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.
[!TIP] Instead of starting containers sequentially, start them in parallel using
Startables.deepStart(postgres, kafka).join();
Extend the base class
Each test class inherits from the base class and reuses the same containers:
class ProductControllerTest extends AbstractIntegrationTest {
ProductRepository productRepository;
@BeforeEach
void setUp() {
productRepository = new ProductRepository(...);
productRepository.deleteAll();
}
@Test
void shouldGetAllProducts() {
// test logic using the shared postgres container
}
}
Avoid a common misconfiguration
A common mistake is combining singleton containers with the @Testcontainers
and @Container annotations:
// DON'T DO THIS — containers will stop after each test class
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class AbstractIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine"));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
The @Testcontainers extension stops containers at the end of each test
class. Subsequent test classes reuse the cached Spring context, but the
containers are already stopped — causing connection failures.
Instead, use a static initializer or @BeforeAll to start the containers,
without the @Testcontainers and @Container annotations.
Summary
- 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/@Containerannotations.
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
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
The problem with H2 for testing
A common practice is to use lightweight databases like H2 or HSQL as in-memory databases for testing while using PostgreSQL, MySQL, or Oracle in production. This approach has significant drawbacks:
- 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
@Testcontainersand@Containerto manage the container lifecycle. - Copies
init-db.sqlinto the container's init directory so PostgreSQL runs it at startup. - Uses
@DynamicPropertySourceto register the container's connection details with Spring Boot. - Tests PostgreSQL-specific features like
ON CONFLICT DO NOTHINGthat 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
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Copy files into containers
Sometimes you need to initialize a container by placing files in a specific
location. For example, PostgreSQL runs SQL scripts from
/docker-entrypoint-initdb.d/ when the container starts.
Create the initialization script
Create src/test/resources/init-db.sql:
create table customers (
id bigint not null,
name varchar not null,
primary key (id)
);
Copy the file into the container
Use withCopyFileToContainer() to copy the SQL script into the container's
init directory:
package com.testcontainers.demo;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.MountableFile;
@Testcontainers
class CustomerServiceTest {
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer(
"postgres:16-alpine"
)
.withCopyFileToContainer(
MountableFile.forClasspathResource("init-db.sql"),
"/docker-entrypoint-initdb.d/"
);
CustomerService customerService;
@BeforeEach
void setUp() {
customerService =
new CustomerService(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword()
);
}
@Test
void shouldGetCustomers() {
customerService.createCustomer(new Customer(1L, "George"));
customerService.createCustomer(new Customer(2L, "John"));
List<Customer> customers = customerService.getAllCustomers();
assertFalse(customers.isEmpty());
}
}
The withCopyFileToContainer(MountableFile, String) method copies init-db.sql
from the classpath into /docker-entrypoint-initdb.d/ inside the container.
PostgreSQL executes scripts in that directory automatically at startup.
You can also copy files from any path on the host:
.withCopyFileToContainer(
MountableFile.forHostPath("/host/path/to/init-db.sql"),
"/docker-entrypoint-initdb.d/"
);
Execute commands inside containers
Some Docker containers provide CLI tools for performing actions. You can use
container.execInContainer(String...) to run any available command inside a
running container.
Example: Create an S3 bucket in LocalStack
The LocalStack module emulates AWS services. To test S3 file uploads, create a bucket before running tests:
package com.testcontainers.demo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.Bucket;
@Testcontainers
class LocalStackTest {
static final String bucketName = "mybucket";
@Container
static LocalStackContainer localStack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:3.4.0")
);
@BeforeAll
static void beforeAll() throws IOException, InterruptedException {
localStack.execInContainer("awslocal", "s3", "mb", "s3://" + bucketName);
org.testcontainers.containers.Container.ExecResult execResult =
localStack.execInContainer("awslocal", "s3", "ls");
String stdout = execResult.getStdout();
int exitCode = execResult.getExitCode();
assertTrue(stdout.contains(bucketName));
assertEquals(0, exitCode);
}
@Test
void shouldListBuckets() {
URI s3Endpoint = localStack.getEndpointOverride(S3);
StaticCredentialsProvider credentialsProvider =
StaticCredentialsProvider.create(
AwsBasicCredentials.create(
localStack.getAccessKey(),
localStack.getSecretKey()
)
);
S3Client s3 = S3Client
.builder()
.endpointOverride(s3Endpoint)
.credentialsProvider(credentialsProvider)
.region(Region.of(localStack.getRegion()))
.build();
List<String> s3Buckets = s3
.listBuckets()
.buckets()
.stream()
.map(Bucket::name)
.toList();
assertTrue(s3Buckets.contains(bucketName));
}
}
The execInContainer("awslocal", "s3", "mb", "s3://mybucket") call runs the
awslocal CLI tool (provided by the LocalStack image) to create an S3 bucket.
You can capture the output and exit code from any command:
Container.ExecResult execResult =
localStack.execInContainer("awslocal", "s3", "ls");
String stdout = execResult.getStdout();
int exitCode = execResult.getExitCode();
[!NOTE] The
withCopyFileToContainer()andexecInContainer()methods are inherited fromGenericContainer, 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
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Create the Spring Boot project
Set up the project
Create a Spring Boot project from Spring Initializr by selecting the Spring Web, Spring Data JPA, PostgreSQL Driver, and Testcontainers starters.
Alternatively, clone the guide repository.
The key dependencies in pom.xml are:
<properties>
<java.version>17</java.version>
<testcontainers.version>2.0.4</testcontainers.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Using the Testcontainers BOM (Bill of Materials) is recommended so that you don't have to repeat the version for every Testcontainers module dependency.
Create the JPA entity
Create Customer.java:
package com.testcontainers.demo;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "customers")
class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
public Customer() {}
public Customer(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
Create the Spring Data JPA repository
package com.testcontainers.demo;
import org.springframework.data.jpa.repository.JpaRepository;
interface CustomerRepository extends JpaRepository<Customer, Long> {}
Add the schema creation script
Create src/main/resources/schema.sql:
create table if not exists customers (
id bigserial not null,
name varchar not null,
email varchar not null,
primary key (id),
UNIQUE (email)
);
Enable schema initialization in src/main/resources/application.properties:
spring.sql.init.mode=always
Create the REST API endpoint
Create CustomerController.java:
package com.testcontainers.demo;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class CustomerController {
private final CustomerRepository repo;
CustomerController(CustomerRepository repo) {
this.repo = repo;
}
@GetMapping("/api/customers")
List<Customer> getAll() {
return repo.findAll();
}
}
Write tests with Testcontainers
To test the REST API, you need a running Postgres database and a started
Spring context. Testcontainers spins up Postgres in a Docker container and
@DynamicPropertySource connects it to Spring.
Write the test
Create CustomerControllerTest.java:
package com.testcontainers.demo;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasSize;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.postgresql.PostgreSQLContainer;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CustomerControllerTest {
@LocalServerPort
private Integer port;
static PostgreSQLContainer postgres = new PostgreSQLContainer(
"postgres:16-alpine"
);
@BeforeAll
static void beforeAll() {
postgres.start();
}
@AfterAll
static void afterAll() {
postgres.stop();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
CustomerRepository customerRepository;
@BeforeEach
void setUp() {
RestAssured.baseURI = "http://localhost:" + port;
customerRepository.deleteAll();
}
@Test
void shouldGetAllCustomers() {
List<Customer> customers = List.of(
new Customer(null, "John", "john@mail.com"),
new Customer(null, "Dennis", "dennis@mail.com")
);
customerRepository.saveAll(customers);
given()
.contentType(ContentType.JSON)
.when()
.get("/api/customers")
.then()
.statusCode(200)
.body(".", hasSize(2));
}
}
Here's what the test does:
@SpringBootTeststarts the full application on a random port.- A
PostgreSQLContainerstarts in@BeforeAlland stops in@AfterAll. @DynamicPropertySourceregisters the container's JDBC URL, username, and password with Spring so that the datasource connects to the test container.@BeforeEachdeletes all customer rows before each test to prevent test pollution.shouldGetAllCustomers()inserts two customers, callsGET /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
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
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Create the Node.js project
Initialize the project
Create a new Node.js project:
$ npm init -y
Add pg, jest, and @testcontainers/postgresql as dependencies:
$ npm install pg --save
$ npm install jest @testcontainers/postgresql --save-dev
Implement the customer repository
Create src/customer-repository.js with functions to manage customers in
PostgreSQL:
async function createCustomerTable(client) {
const sql =
"CREATE TABLE IF NOT EXISTS customers (id INT NOT NULL, name VARCHAR NOT NULL, PRIMARY KEY (id))";
await client.query(sql);
}
async function createCustomer(client, customer) {
const sql = "INSERT INTO customers (id, name) VALUES($1, $2)";
await client.query(sql, [customer.id, customer.name]);
}
async function getCustomers(client) {
const sql = "SELECT * FROM customers";
const result = await client.query(sql);
return result.rows;
}
module.exports = { createCustomerTable, createCustomer, getCustomers };
The module provides three functions:
createCustomerTable()creates thecustomerstable 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
beforeAllblock starts a real PostgreSQL container usingPostgreSqlContainer. It then creates apgclient connected to the container and sets up thecustomerstable. - The
afterAllblock 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
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
psycopgto interact with the database - Write integration tests using
testcontainers-pythonandpytest - Manage container lifecycle with pytest fixtures
Prerequisites
- Python 3.10+
- pip
- A Docker environment supported by Testcontainers
[!NOTE] If you're new to Testcontainers, visit the Testcontainers overview to learn more about Testcontainers and the benefits of using it.
Create the Python project
Initialize the project
Start by creating a Python project with a virtual environment:
$ mkdir tc-python-demo
$ cd tc-python-demo
$ python3 -m venv venv
$ source venv/bin/activate
This guide uses psycopg3 to interact with the Postgres database, pytest for testing, and testcontainers-python for running a PostgreSQL database in a container.
Install the dependencies:
$ pip install "psycopg[binary]" pytest testcontainers[postgres]
$ pip freeze > requirements.txt
The pip freeze command generates a requirements.txt file so that others
can install the same package versions using pip install -r requirements.txt.
Create the database helper
Create a db/connection.py file with a function to get a database connection:
import os
import psycopg
def get_connection():
host = os.getenv("DB_HOST", "localhost")
port = os.getenv("DB_PORT", "5432")
username = os.getenv("DB_USERNAME", "postgres")
password = os.getenv("DB_PASSWORD", "postgres")
database = os.getenv("DB_NAME", "postgres")
return psycopg.connect(f"host={host} dbname={database} user={username} password={password} port={port}")
Instead of hard-coding the database connection parameters, the function uses environment variables. This makes it possible to run the application in different environments without changing code.
Create the business logic
Create a customers/customers.py file and define the Customer class:
class Customer:
def __init__(self, cust_id, name, email):
self.id = cust_id
self.name = name
self.email = email
def __str__(self):
return f"Customer({self.id}, {self.name}, {self.email})"
Add a create_table() function to create the customers table:
from db.connection import get_connection
def create_table():
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE customers (
id serial PRIMARY KEY,
name varchar not null,
email varchar not null unique)
""")
conn.commit()
The function obtains a database connection using get_connection() and creates
the customers table. The with statement automatically closes the connection
when done.
Add the remaining CRUD functions:
def create_customer(name, email):
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO customers (name, email) VALUES (%s, %s)", (name, email))
conn.commit()
def get_all_customers() -> list[Customer]:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT * FROM customers")
return [Customer(cid, name, email) for cid, name, email in cur]
def get_customer_by_email(email) -> Customer:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT id, name, email FROM customers WHERE email = %s", (email,))
(cid, name, email) = cur.fetchone()
return Customer(cid, name, email)
def delete_all_customers():
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM customers")
conn.commit()
[!NOTE] To keep it straightforward for this guide, each function creates a new connection. In a real-world application, use a connection pool to reuse connections.
Write tests with Testcontainers
You'll create a PostgreSQL container using Testcontainers and use it for all the tests. Before each test, you'll delete all customer records so that tests run with a clean database.
Set up pytest fixtures
This guide uses pytest fixtures for setup and teardown logic. A recommended approach is to use finalizers to guarantee cleanup runs even if setup fails:
@pytest.fixture
def setup(request):
# setup code
def cleanup():
# teardown code
request.addfinalizer(cleanup)
return some_value
Create the test file
Create a tests/__init__.py file with empty content to enable pytest
auto-discovery.
Then create tests/test_customers.py with the fixtures:
import os
import pytest
from testcontainers.postgres import PostgresContainer
from customers import customers
postgres = PostgresContainer("postgres:16-alpine")
@pytest.fixture(scope="module", autouse=True)
def setup(request):
postgres.start()
def remove_container():
postgres.stop()
request.addfinalizer(remove_container)
os.environ["DB_CONN"] = postgres.get_connection_url()
os.environ["DB_HOST"] = postgres.get_container_host_ip()
os.environ["DB_PORT"] = str(postgres.get_exposed_port(5432))
os.environ["DB_USERNAME"] = postgres.username
os.environ["DB_PASSWORD"] = postgres.password
os.environ["DB_NAME"] = postgres.dbname
customers.create_table()
@pytest.fixture(scope="function", autouse=True)
def setup_data():
customers.delete_all_customers()
Here's what the fixtures do:
- The
setupfixture hasscope="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 thecustomerstable. A cleanup function removes the container after all tests complete. - The
setup_datafixture hasscope="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-demousing thedocker network createcommand:$ 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.dockerargument 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.ioThen 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-dockerOnce 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.yamlfile, 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.ioYou 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
phpmyadminservice 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 upand 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
commandis 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.yamlfile:$ docker compose -f compose-native.yaml upOpening 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 downto stop the containers and stop the Yarn apps by hittingctrl+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.
[!TIP]
Gordon, Docker's AI assistant, can generate Docker assets for your project. Ask Gordon to create a Dockerfile, Compose file, and
.dockerignoretailored to your application.
Step 1: Create the Dockerfile
Before creating a Dockerfile, you need to choose a base image. You can either use the Node.js Official Image or a Docker Hardened Image (DHI) from the Hardened Image catalog.
Choosing DHI offers the advantage of a production-ready image that is lightweight and secure. For more information, see Docker Hardened Images.
[!IMPORTANT] This guide uses a stable Node.js LTS image tag that is considered secure when the guide is written. Because new releases and security patches are published regularly, the tag shown here may no longer be the safest option when you follow the guide. Always review the latest available image tags and select a secure, up-to-date version before building or deploying your application.
Official Node.js Docker Images: https://hub.docker.com/_/node
{{< tabs >}} {{< tab name="Using Docker Hardened Images" >}} Docker Hardened Images (DHIs) are available for Node.js in the Docker Hardened Images catalog. Docker Hardened Images are freely available to everyone with no subscription required. You can pull and use them like any other Docker image after signing in to the DHI registry. For more information, see the DHI quickstart guide.
-
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;"]
[!NOTE] We are using nginx-unprivileged instead of the standard Nginx image to follow security best practices. Running as a non-root user in the final image:
- 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.
[!NOTE] This helps:
- Reduce image size
- Speed up the build process
- Prevent sensitive or unnecessary files (like
.env,.git, ornode_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:
[!NOTE] To learn more about configuring Nginx, see the official Nginx documentation.
worker_processes auto;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
charset utf-8;
access_log off;
error_log /dev/stderr warn;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000;
gzip on;
gzip_comp_level 6;
gzip_proxied any;
gzip_min_length 256;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|map)$ {
expires 1y;
access_log off;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff;
}
error_page 404 /index.html;
}
}
Step 5: Build the Vue.js application image
With your custom configuration in place, you're now ready to build the Docker image for your Vue.js application.
The updated setup includes:
- 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.
[!NOTE] The
docker buildcommand 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
[!NOTE] For more information about Compose commands, see the Compose CLI reference.
Use containers for Vue.js development
Prerequisites
Complete Containerize Vue.js application.
Overview
In this section, you'll set up both production and development environments for your Vue.js application using Docker Compose. This approach streamlines your workflow—delivering a lightweight, static site via Nginx in production, and providing a fast, live-reloading dev server with Compose Watch for efficient local development.
You’ll learn how to:
- 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-prodservice builds and serves your static production app using Nginx. - The
vuejs-devservice runs your Vue.js development server with live reload and hot module replacement. watchtriggers file sync with Compose Watch.
[!NOTE] For more details, see the official guide: Use Compose Watch.
After completing the previous steps, your project directory should now contain the following files:
├── docker-vuejs-sample/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── .dockerignore
│ ├── compose.yaml
│ └── nginx.conf
Step 4: Start Compose Watch
Run the following command from the project root to start the container in watch mode
$ docker compose watch vuejs-dev
Step 5: Test Compose Watch with Vue.js
To confirm that Compose Watch is functioning correctly:
-
Open the
src/App.vuefile 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-testservice defined in yourcompose.yamlfile. - Execute the
npm run testscript using the same environment as development. - Automatically remove the container after the tests complete
docker compose run --rmcommand.
You should see output similar to the following:
Test Files: 1 passed (1)
Tests: 1 passed (1)
Start at: 16:50:55
Duration: 718ms
[!NOTE] For more information about Compose commands, see the Compose CLI reference.
Summary
In this section, you learned how to run unit tests for your Vue.js application inside a Docker container using Vitest and Docker Compose.
What you accomplished:
- Created a
vuejs-testservice incompose.yamlto isolate test execution. - Reused the development
Dockerfile.devto 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 runCLI 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:
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-endpointdirectory$ 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 -dAfter a moment, the application will be up and running.

You can check the logs by selecting the
wiremock-node-dockercontainer:
-
Test the Mock API.
$ curl http://localhost:8080/api/v1/getWeather\?city\=BengaluruIt 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:
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 APPSon 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
.envfile:[!TIP] To prevent conflicts, ensure that any existing environment variables named
API_ENDPOINT_BASEorACCUWEATHER_API_KEYare removed before modifying the.envfile.Run the following command on your terminal:
unset API_ENDPOINT_BASE unset ACCUWEATHER_API_KEYIt’s time to set the environment variables in the
.envfile:ACCUWEATHER_API_KEY=XXXXXX API_ENDPOINT_BASE=http://dataservice.accuweather.comMake sure to populate
ACCUWEATHER_API_KEYwith the correct value. -
Install the dependencies
Run the following command to install the required packages:
$ npm installThis will install all the packages listed in your
package.jsonfile. 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 startYou 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:5001Keep 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/getWeatherendpoint, and you're providing the query parametercity=Bengaluru. Once you execute the command, the server processes this request, fetches the data and returns it as a response, whichcurlwill 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.

