Using CVE Visualizations
Getting started with the CVE Visualization feature.
For the complete documentation index, see llms.txt.
Chainguard’s WordPress container image is a drop-in replacement for the official WordPress FPM-Alpine image, with significantly fewer vulnerabilities than the standard image. It includes a distroless variant for production use that removes shells, package managers, and other unnecessary components. The image ships with the latest PHP and WordPress versions and all required PHP extensions.
This guide covers three ways to use the WordPress Chainguard Container to build and run WordPress projects.
Multi-stage builds are a Docker feature that allow you to use multiple FROM statements in a single Dockerfile, where each statement begins a new build stage. In a typical pattern, an early stage uses a full-featured builder image to compile code or generate artifacts, while a later stage uses a minimal runtime image and copies in only what's needed to run the application. Only what you explicitly copy from one stage carries forward — everything else is discarded when that stage completes.
This approach has significant security benefits. Build tools like compilers, shells, and package managers are broadly exploitable general-purpose utilities that expand an image's attack surface. By leaving them behind in the builder stage, the runtime image has fewer packages, fewer potential CVEs, and a smaller blast radius in the event of a compromise. Reducing unnecessary components also improves observability and makes risk assessment easier, since every package in the final image can be directly tied to a runtime requirement.
Chainguard Containers are designed with this pattern in mind. Most have a :latest-dev development variant suited for use as a builder stage, and a corresponding :latest (or -slim) standard image for the distroless runtime. For example, a Go application can be compiled in the go:latest-dev builder stage and its binary copied into a static or glibc-dynamic runtime image — with no Go toolchain in the final container.
This guide requires Docker. Download and install it from the official Docker website if you don’t have it.
The images in this guide require a free Chainguard account. Sign up at chainguard.dev if you don’t have one, then authenticate:
chainctl auth loginIf you encounter credential errors when pulling images, pull them individually before running docker compose:
docker pull cgr.dev/chainguard/wordpress:latest-dev
docker pull cgr.dev/chainguard/wordpress:latest
docker pull cgr.dev/chainguard/mariadb
docker pull cgr.dev/chainguard/nginxClone the demos repository to your local machine:
git clone git@github.com:chainguard-dev/edu-images-demos.gitNavigate to the wordpress demo directory:
cd edu-images-demos/php/wordpressYou’ll find three directories, one for each example in this guide.
The latest-dev variant of the Chainguard WordPress Container lets you run a fresh WordPress installation and explore the setup wizard. Changes you make won’t persist after you stop the environment — the next example shows how to persist customizations using a volume.
The files for this example are in the 01-preview directory. Open docker-compose.yml in your editor to follow along.
This example includes a Dockerfile that copies WordPress source files into the document root and sets ownership at build time:
FROM cgr.dev/chainguard/wordpress:latest-dev
USER root
RUN cp -r /usr/src/wordpress/. /var/www/html/ && \
cp /var/www/html/wp-config-docker.php /var/www/html/wp-config.php && \
chown -R php:php /var/www/html
USER phpThe docker-compose.yml for this example:
services:
app:
build: .
image: wordpress-preview
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: mariadb
WORDPRESS_DB_USER: $WORDPRESS_DB_USER
WORDPRESS_DB_PASSWORD: $WORDPRESS_DB_PASSWORD
WORDPRESS_DB_NAME: $WORDPRESS_DB_NAME
volumes:
- document-root:/var/www/html
nginx:
image: cgr.dev/chainguard/nginx
restart: unless-stopped
ports:
- 8000:8080
volumes:
- document-root:/var/www/html
- ./nginx.conf:/etc/nginx/nginx.conf
mariadb:
image: cgr.dev/chainguard/mariadb
restart: unless-stopped
environment:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
MARIADB_USER: $WORDPRESS_DB_USER
MARIADB_PASSWORD: $WORDPRESS_DB_PASSWORD
MARIADB_DATABASE: $WORDPRESS_DB_NAME
ports:
- 3306:3306
volumes:
document-root:This Docker Compose file defines three services: app, nginx, and mariadb:
app service builds a local image using the included Dockerfile, which copies WordPress source files from /usr/src/wordpress into the document root and renames wp-config-docker.php to wp-config.php. That config file reads database credentials from environment variables at runtime. The document-root volume is shared between app and nginx.nginx service uses the Chainguard nginx Container, configured to serve the WordPress application on port 8000.mariadb service uses the Chainguard MariaDB Container, configured with the environment variables needed to create the WordPress database.The environment variables are stored in a .env file in the same directory. To view them, run:
cat .envWORDPRESS_DB_HOST=mariadb
WORDPRESS_DB_USER=wp-user
WORDPRESS_DB_PASSWORD=wp-password
WORDPRESS_DB_NAME=wordpressYou can change these values as needed. The .env file is hidden and won’t appear in most file explorers, but you can open it in any terminal text editor.
Build the image and start the services:
docker compose up --buildOpen http://localhost:8000 in your browser to reach the WordPress installation page. Follow the on-screen instructions to complete setup. Any customizations will be lost when you stop the environment.
To stop the services, press CTRL+C in the terminal, then run:
docker compose downThis removes the containers and networks. The next example shows how to mount a volume so that customizations like themes and plugins persist.
To keep customizations — themes, plugins, and other changes — between container rebuilds, you need a volume with the correct permissions. This requires a user inside the container with the same UID as your host user. This example uses a custom Dockerfile to add a wordpress user with a configurable UID (defaulting to 1000, the typical UID for a non-root user on Linux) and sets ownership of the document root accordingly.
Navigate to 02-customizing to follow along. Here’s the Dockerfile:
FROM cgr.dev/chainguard/wordpress:latest-dev
ARG UID=1000
USER root
RUN cp -r /usr/src/wordpress/. /var/www/html/ && \
cp /var/www/html/wp-config-docker.php /var/www/html/wp-config.php && \
cp -r /usr/src/wordpress/wp-content /usr/src/wp-content-default
RUN addgroup wordpress && adduser -SD -u "$UID" -s /bin/bash wordpress wordpress
RUN chown -R wordpress:wordpress /var/www/html /usr/src/wp-content-default
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
USER wordpress
ENTRYPOINT ["/docker-entrypoint.sh"]In addition to creating the wordpress user, the Dockerfile copies WordPress source files into the document root at build time and saves a copy of the default wp-content directory to /usr/src/wp-content-default. A custom entrypoint script uses that copy to populate the host-mounted wp-content directory on first run if it doesn’t yet contain a themes subdirectory:
#!/bin/sh
# Populate wp-content from defaults on first run (when themes directory is absent)
if [ ! -d /var/www/html/wp-content/themes ]; then
cp -r /usr/src/wp-content-default/. /var/www/html/wp-content/
fi
exec php-fpmThe docker-compose.yml file references the custom Dockerfile and accepts a UID build argument:
services:
app:
image: wordpress-local-dev
build:
context: .
dockerfile: Dockerfile
args:
UID: 1000
user: wordpress
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: mariadb
WORDPRESS_DB_USER: $WORDPRESS_DB_USER
WORDPRESS_DB_PASSWORD: $WORDPRESS_DB_PASSWORD
WORDPRESS_DB_NAME: $WORDPRESS_DB_NAME
volumes:
- ./wp-content:/var/www/html/wp-content
- document-root:/var/www/html
nginx:
image: cgr.dev/chainguard/nginx
restart: unless-stopped
ports:
- 8000:8080
volumes:
- document-root:/var/www/html
- ./nginx.conf:/etc/nginx/nginx.conf
mariadb:
image: cgr.dev/chainguard/mariadb
restart: unless-stopped
environment:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
MARIADB_USER: $WORDPRESS_DB_USER
MARIADB_PASSWORD: $WORDPRESS_DB_PASSWORD
MARIADB_DATABASE: $WORDPRESS_DB_NAME
ports:
- 3306:3306
volumes:
document-root:Only the app service differs from the previous example. The build section references the custom Dockerfile and sets UID to 1000 by default; pass your own UID at build time to match your host user. The bind mount at ./wp-content persists the WordPress content directory to the host.
Build the image, passing your UID as a build argument:
docker compose build --build-arg UID=$(id -u) appOnce the build completes, start the services:
docker compose upOpen http://localhost:8000 in your browser to access the WordPress installation.
In a separate terminal, check that the wp-content directory was populated with the default WordPress themes and plugins:
❯ ls -la wp-content
total 24
drwxrwxr-x 4 erika erika 4096 Jul 18 21:16 .
drwxrwxr-x 3 erika erika 4096 Jul 18 21:15 ..
-rw-rw-r-- 1 erika erika 14 Jul 18 21:05 .gitignore
-rw-r--r-- 1 erika 65533 28 Jan 1 1970 index.php
drwxr-xr-x 2 erika 65533 4096 Jul 18 21:16 plugins
drwxr-xr-x 16 erika 65533 4096 Jul 18 21:16 themesThe matching UID between the container’s wordpress user and your host user is what allows files written by the container to be owned by your host account.
Any themes and plugins you install now persist between container rebuilds.
To stop the services, press CTRL+C in the terminal, then run:
docker compose downThe next example shows how to build a distroless WordPress image for production.
This example uses a multi-stage Docker build to produce a distroless image with a smaller attack surface. The distroless image includes only the dependencies needed to run WordPress, without a shell or package manager.
The key difference from the previous examples is that all WordPress files — including any customizations in wp-content — are baked into the image at build time rather than populated at runtime. This makes the image self-contained: it doesn’t rely on init steps or host volumes. Adding custom content at build time increases the final image size, but prevents filesystem changes once the container is running.
To demonstrate custom content, this example includes the Cue blogging theme and the Imsanity image-resizing plugin.
Navigate to 03-distroless to follow along. Here’s the Dockerfile:
FROM cgr.dev/chainguard/wordpress:latest-dev AS builder
#copy wp-content folder
COPY ./wp-content /usr/src/wordpress/wp-content
USER root
#copy WordPress source to document root and set up config
RUN cp -r /usr/src/wordpress/. /var/www/html/ && \
cp /var/www/html/wp-config-docker.php /var/www/html/wp-config.php
FROM cgr.dev/chainguard/wordpress:latest
COPY --from=builder --chown=php:php /var/www/html /var/www/htmlThe COPY instruction places your local wp-content into /usr/src/wordpress/wp-content before the RUN step merges it with the rest of the WordPress source. USER root is required because the WordPress source directory in this image is only readable by root. The RUN command copies everything to /var/www/html and renames wp-config-docker.php to wp-config.php, which reads database credentials from environment variables at runtime. The final stage copies the populated document root into the distroless image with php:php ownership.
The docker-compose.yml file references the custom Dockerfile:
services:
app:
image: wordpress-local-distroless
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: mariadb
WORDPRESS_DB_USER: $WORDPRESS_DB_USER
WORDPRESS_DB_PASSWORD: $WORDPRESS_DB_PASSWORD
WORDPRESS_DB_NAME: $WORDPRESS_DB_NAME
WORDPRESS_CONFIG_EXTRA: |
# Disable plugin and theme update and installation
define( 'DISALLOW_FILE_MODS', true );
# Disable automatic updates
define( 'AUTOMATIC_UPDATER_DISABLED', true );
volumes:
- document-root:/var/www/html
nginx:
image: cgr.dev/chainguard/nginx
restart: unless-stopped
ports:
- 8000:8080
volumes:
- document-root:/var/www/html
- ./nginx.conf:/etc/nginx/nginx.conf
mariadb:
image: cgr.dev/chainguard/mariadb
restart: unless-stopped
environment:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
MARIADB_USER: $WORDPRESS_DB_USER
MARIADB_PASSWORD: $WORDPRESS_DB_PASSWORD
MARIADB_DATABASE: $WORDPRESS_DB_NAME
ports:
- 3306:3306
volumes:
document-root:Build and start the environment:
docker compose up --buildThis WordPress setup behaves like the previous examples, but the image is self-contained — it requires no host volumes and allows no new package installations or shell access. The WORDPRESS_CONFIG_EXTRA environment variable disables theme and plugin installation and automatic updates, preventing filesystem changes inside the running container.
To stop the services, press CTRL+C in the terminal, then run:
docker compose downTo keep your WordPress installation up to date, use digestabot, a GitHub Action that works like Dependabot — it sends a pull request to your repository whenever a new version of a container image is available, ensuring you’re always running the latest WordPress version from Wolfi.
If your project requires a more specific set of packages that aren't included within the general-purpose WordPress Chainguard Container, you'll first need to check if the package you want is already available on the wolfi-os repository.
Note: If you're building on top of a container image other than the wolfi-base container image, the image will run as a non-root user. Because of this, if you need to install packages with
apk addyou need to use theUSER rootdirective.
If the package is available, you can use the wolfi-base image in a Dockerfile and install what you need with apk, then use the resulting image as base for your app.
Check the "Using the wolfi-base Container" section of our images quickstart guide for more information.
If the packages you need are not available, you can build your own apks using melange. Please refer to this guide for more information.
Last updated: 2026-06-09 00:09