---
title: "Building a custom Docker image"
description: "Build custom FrankenPHP Docker images, install PHP extensions and Caddy modules, run as non-root, harden with distroless, and enable worker mode by default."
sidebar:
  label: "Docker images"
  order: 0
seo:
  title: "FrankenPHP Docker image: build, configure, extend"
---
[FrankenPHP Docker images](https://hub.docker.com/r/dunglas/frankenphp) are based on [official PHP images](https://hub.docker.com/_/php/).
Debian and Alpine Linux variants are provided for popular architectures.
Debian variants are recommended.

Variants for PHP 8.2, 8.3, 8.4 and 8.5 are provided.

The tags follow this pattern: `dunglas/frankenphp:<frankenphp-version>-php<php-version>-<os>`

- `<frankenphp-version>` and `<php-version>` are version numbers of FrankenPHP and PHP respectively, ranging from major (e.g. `1`), minor (e.g. `1.2`) to patch versions (e.g. `1.2.3`).
- `<os>` is either `trixie` (for Debian Trixie), `bookworm` (for Debian Bookworm), or `alpine` (for the latest stable version of Alpine).

[Browse tags](https://hub.docker.com/r/dunglas/frankenphp/tags).

## How to use the FrankenPHP Docker images

Create a `Dockerfile` in your project:

```dockerfile
FROM dunglas/frankenphp

COPY . /app/public
```

Then, run these commands to build and run the Docker image:

```console
docker build -t my-php-app .
docker run -it --rm --name my-running-app my-php-app
```

## How to tweak the FrankenPHP Docker configuration

For convenience, [a default `Caddyfile`](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile) containing
useful environment variables is provided in the image.

## How to install more PHP extensions

The [`docker-php-extension-installer`](https://github.com/mlocati/docker-php-extension-installer) script is provided in the base image.
Adding additional PHP extensions is straightforward:

```dockerfile
FROM dunglas/frankenphp

# add additional extensions here:
RUN install-php-extensions \
	pdo_mysql \
	gd \
	intl \
	zip \
	opcache
```

## How to install more Caddy modules

FrankenPHP is built on top of Caddy, and all [Caddy modules](https://caddyserver.com/docs/modules/) can be used with FrankenPHP.

The easiest way to install custom Caddy modules is to use [xcaddy](https://github.com/caddyserver/xcaddy):

```dockerfile
FROM dunglas/frankenphp:builder AS builder

# Copy xcaddy in the builder image
COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy

# CGO must be enabled to build FrankenPHP
RUN CGO_ENABLED=1 \
    XCADDY_SETCAP=1 \
    XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx,deprecated_topic,deprecated_claim" \
    CGO_CFLAGS=$(php-config --includes) \
    CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
    xcaddy build \
        --output /usr/local/bin/frankenphp \
        --with github.com/dunglas/frankenphp=./ \
        --with github.com/dunglas/frankenphp/caddy=./caddy/ \
        --with github.com/dunglas/caddy-cbrotli \
        # Mercure and Vulcain are included in the official build, but feel free to remove them
        --with github.com/dunglas/mercure/caddy \
        --with github.com/dunglas/vulcain/caddy
        # Add extra Caddy modules here

FROM dunglas/frankenphp AS runner

# Replace the official binary with the one containing your custom modules
COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp
```

The `builder` image provided by FrankenPHP contains a compiled version of `libphp`.
[Builder images](https://hub.docker.com/r/dunglas/frankenphp/tags?name=builder) are provided for all versions of FrankenPHP and PHP, both for Debian and Alpine.

<div class="fp-callout" data-callout="tip">
<p class="fp-callout-title">Tip</p>

If you're using Alpine Linux and Symfony,
you may need to [increase the default stack size](/docs/compile#using-xcaddy).

</div>

## Enabling the worker mode by default

Set the `FRANKENPHP_CONFIG` environment variable to start FrankenPHP with a worker script:

```dockerfile
FROM dunglas/frankenphp

# ...

ENV FRANKENPHP_CONFIG="worker ./public/index.php"
```

## Using a volume in development

To develop easily with FrankenPHP, mount the directory from your host containing the source code of the app as a volume in the Docker container:

```console
docker run -v $PWD:/app/public -p 80:80 -p 443:443 -p 443:443/udp --tty my-php-app
```

<div class="fp-callout" data-callout="tip">
<p class="fp-callout-title">Tip</p>

The `--tty` option provides nice human-readable logs instead of JSON logs.

</div>

With Docker Compose:

```yaml
# compose.yaml

services:
  php:
    image: dunglas/frankenphp
    # uncomment the following line if you want to use a custom Dockerfile
    #build: .
    # uncomment the following line if you want to run this in a production environment
    # restart: always
    ports:
      - "80:80" # HTTP
      - "443:443" # HTTPS
      - "443:443/udp" # HTTP/3
    volumes:
      - ./:/app/public
      - caddy_data:/data
      - caddy_config:/config
    # comment the following line in production, it provides nice human-readable logs in dev
    tty: true

# Volumes needed for Caddy certificates and configuration
volumes:
  caddy_data:
  caddy_config:
```

## Running as a non-root user

FrankenPHP can run as a non-root user in Docker.

Here is a sample `Dockerfile` doing this:

```dockerfile
FROM dunglas/frankenphp

ARG USER=appuser

RUN <<-EOF
	# Use "adduser -D ${USER}" for alpine based distros
	useradd ${USER}
	# Add additional capability to bind to port 80 and 443
	setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp
	# Give write access to /config/caddy and /data/caddy
	chown -R ${USER}:${USER} /config/caddy /data/caddy
EOF

USER ${USER}
```

### Running with no capabilities

Even when running rootless, FrankenPHP needs the `CAP_NET_BIND_SERVICE` capability to bind the
web server on privileged ports (80 and 443).

If you expose FrankenPHP on a non-privileged port (1024 and above), it's possible to run
the webserver as a non-root user, and without the need for any capability:

```dockerfile
FROM dunglas/frankenphp

ARG USER=appuser

RUN <<-EOF
	# Use "adduser -D ${USER}" for alpine based distros
	useradd ${USER}
	# Remove default capability
	setcap -r /usr/local/bin/frankenphp
	# Give write access to /config/caddy and /data/caddy
	chown -R ${USER}:${USER} /config/caddy /data/caddy
EOF

USER ${USER}
```

Next, set the `SERVER_NAME` environment variable to use an unprivileged port.
Example: `:8000`

## FrankenPHP Docker image updates

The Docker images are built:

- when a new release is tagged
- daily at 4 am UTC, if new versions of the official PHP images are available

## Hardening images

To further reduce the attack surface and size of your FrankenPHP Docker images, it's also possible to build them on top of a
[Google distroless](https://github.com/GoogleContainerTools/distroless) or
[Docker hardened](https://www.docker.com/products/hardened-images) image.

<div class="fp-callout" data-callout="warning">
<p class="fp-callout-title">Warning</p>

These minimal base images do not include a shell or package manager, which makes debugging more difficult.
They are therefore recommended only for production if security is a high priority.

</div>

When adding additional PHP extensions, you will need an intermediate build stage:

```dockerfile
FROM dunglas/frankenphp AS builder

# Add additional PHP extensions here
RUN install-php-extensions pdo_mysql pdo_pgsql #...

# Copy shared libs of frankenphp and all installed extensions to temporary location
# You can also do this step manually by analyzing ldd output of frankenphp binary and each extension .so file
RUN <<-EOF
	apt-get update
	apt-get install -y --no-install-recommends libtree
	mkdir -p /tmp/libs
	for target in $(which frankenphp) \
		$(find "$(php -r 'echo ini_get("extension_dir");')" -maxdepth 2 -name "*.so"); do
		libtree -pv "$target" 2>/dev/null | grep -oP '(?:── )\K/\S+(?= \[)' | while IFS= read -r lib; do
			[ -f "$lib" ] && cp -n "$lib" /tmp/libs/
		done
	done
EOF


# Distroless Debian base image, make sure this matches the Debian version of the builder
FROM gcr.io/distroless/base-debian13
# Docker hardened image alternative
# FROM dhi.io/debian:13

COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp
COPY --from=builder /usr/local/lib/php/extensions /usr/local/lib/php/extensions
COPY --from=builder /tmp/libs /usr/lib

COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
COPY --from=builder /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini

# Config and data dirs must be writable for nonroot, even on a read-only root filesystem
ENV XDG_CONFIG_HOME=/config XDG_DATA_HOME=/data
COPY --from=builder --chown=nonroot:nonroot /data /data
COPY --from=builder --chown=nonroot:nonroot /config /config

# Copy your app (kept root-owned) and Caddyfile
COPY . /app
COPY Caddyfile /etc/caddy/Caddyfile

USER nonroot
WORKDIR /app

ENTRYPOINT ["/usr/local/bin/frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
```

## Development versions

Development versions are available in the [`dunglas/frankenphp-dev`](https://hub.docker.com/repository/docker/dunglas/frankenphp-dev) Docker repository.
A new build is triggered every time a commit is pushed to the main branch of the GitHub repository.

The `latest*` tags point to the head of the `main` branch.
Tags of the form `sha-<git-commit-hash>` are also available.
