Local Development with Docker

Craft will run in any environment that meets its base requirements—and with a bit of configuration, it can take advantage of all kinds of modern development tools and infrastructure technology.

We recommend DDEV for the vast majority of projects’ local development needs—but we understand that some developers and teams want more control over their environment, or greater parity with their production infrastructure.

This article covers a minimal Docker Compose configuration that meets Craft’s requirements, and explores a couple of opportunities for more advanced tooling.

Official Docker images can be found in our craftcms/docker repository. We’ll be combining these base images into a fully-functional application with Docker Compose, but you are free to use some, all, or none of them in your own containerized environment.

To run Craft, we need at least three pieces:

  • An HTTP server: A process that serves files and marshals data between an HTTP client (like a web browser) and the PHP interpreter. Oftentimes, this is Apache or nginx.
  • A PHP interpreter: The program that loads, compiles, and executes PHP scripts required by the application to generate a response;
  • A database: The storage medium for the content and relationships that comprise your site (well, everything except the files you might upload);

Later, we'll cover adding a centralized session and cache storage service (Redis) and a tool for auditing outbound email (Mailhog).

The rest of this guide assumes you have a Docker runtime and client installed. The official Docker Desktop client is a great way to start—but we recommend following the same platform-specific steps as you would to install Docker with the goal of using DDEV. All instructions are provided for UNIX-based systems—Windows users may need to adapt some concepts based on WSL2 features!

Beyond Docker, though, you don't need any additional software installed on your host machine. Everything will run inside of isolated, disposable containers.

Existing Installations #

If you have an existing Craft project that you would like to “Dockerize,” you can skip directly to Assembling the Compose File.

New Projects #

Starting a project from scratch requires one additional step.

Gathering Project Files #

Let’s treat this like any other new project, and get the core Craft files scaffolded—but given our environment will eventually be Docker-driven, we might as well use the platform to our advantage and run the default starter project command in a one-off container.

# Create and move into a new directory:
mkdir craft-docker
cd craft-docker

# Bootstrap a new Craft installation with Composer:
docker run \
  --rm \
  --volume $PWD:/app \
  composer \
  composer create-project craftcms/craft . --ignore-platform-reqs

You will see some output from Docker, if this is your first time using the composer image. (The Docker runtime takes care of figuring out which container image(s) and “layers” are required, and downloads them in parallel.)

After that, Composer takes over and unpacks the starter project into the current working directory ($PWD, mounted into the container as /app), and installs all its dependencies. This one-off container doesn't meet all of Craft’s requirements, so we’ve temporarily set the --ignore-platform-reqs Composer flag. Future commands won’t need this!

At the end of the create-project process, you will have the standard folder structure for a Craft project.

For the curious among you, here’s a breakdown of the entire docker run command:

  • The --rm flag cleans up the container after the Composer process ends (we won't be using it again, so that's perfect).
  • --volume $PWD:/app mounts the current folder to the /app directory inside the container and allows the files Composer downloads or creates to be copied to your host machine.
  • A backslash (\) at the end of each line allows the bash script to span multiple lines.
  • composer is intentionally duplicated on the last two lines—the first use is the name of the container in the Docker registry, and the second is the command we're running inside the container.

Assembling the Compose File #

Whether you've started with a fresh project, or are adding Docker to an existing project, the process of building the Compose file (docker-compose.yml) is the same.

A complete docker-compose.yml example is available in our official Docker images repository. If you’re comfortable hacking independently, you can start with that instead of an empty configuration!

Open your project directory in the text editor or IDE of your choice, and create a new file at the root, named docker-compose.yml. At minimum, a Compose file must contain at least a services key:

services:

Recall the essential components of a Craft environment: an HTTP server, a PHP interpreter, and a database. The first two are combined in our own craftcms/nginx image, but the last one we’ll have to reach for the community-maintained (but “Docker Official”) postgres:13-alpine image.

The first service we need will be named web:

services:
  web:
    image: craftcms/nginx:8.1
    ports:
      - 8080:8080
    volumes:
      - .:/app

The database service requires a bit of additional configuration:

services:
  # ...

  db:
    image: postgres:13-alpine
    ports:
      - 5432:5432
    environment:
      POSTGRES_DB: db
      POSTGRES_USER: db
      POSTGRES_PASSWORD: db
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:

Note the new top-level volumes key: this tells Docker that we want a persistent, named storage volume for our database content that will survive any restarts of the project—including through major configuration changes like Postgres version updates. We use this volume in the db service, binding Postgres’s data directory to it.

We’ve also defined a few environment variables specific to the database container. Let’s copy those into our .env file, so Craft knows how to connect:

# ...

CRAFT_DB_DRIVER=pgsql
CRAFT_DB_SERVER=db
CRAFT_DB_PORT=5432
CRAFT_DB_DATABASE=db
CRAFT_DB_USER=db
CRAFT_DB_PASSWORD=db
CRAFT_DB_SCHEMA=public
CRAFT_DB_TABLE_PREFIX=

For projects that require MySQL, replace the db service’s image with mysql

services:
  # ...
  db:
    image: mysql
    environment:
      MYSQL_DATABASE: db
      MYSQL_USER: db
      MYSQL_PASSWORD: db
      # We aren’t going to use this,
      # but it's required by the image:
      MYSQL_ROOT_PASSWORD: root

…and update the corresponding values in your .env file:

CRAFT_DB_DRIVER=mysql
CRAFT_DB_SERVER=db
CRAFT_DB_PORT=3306
CRAFT_DB_DATABASE=db
CRAFT_DB_USER=db
CRAFT_DB_PASSWORD=db
CRAFT_DB_SCHEMA=public
CRAFT_DB_TABLE_PREFIX=

All together, your docker-compose.yml file should look like this:

services:
  web:
    image: craftcms/nginx:8.1
    ports:
      - 8080:8080
    volumes:
      - .:/app

  db:
    image: postgres:13-alpine
    ports:
      - 5432:5432
    environment:
      POSTGRES_DB: db
      POSTGRES_USER: db
      POSTGRES_PASSWORD: db
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:

Running your Application #

To start your services, run this command from your project directory:

docker compose up

Docker will pull the images, then boot your containers. You should see some log messages for both containers we defined, with corresponding -web-1 and -db-1 suffixes.

Port conflicts can be fixed by shutting down other containers or programs that might use them. This is a common problem when switching from another HTTP server like MAMP or Valet, or when using Node tools that run their own dev servers. If you are unable to determine the cause of a conflict, you may update the ports value in the web component to 9876:8080 or another available port. The “right” side of this mapping represents the container’s internal port, and must remain 8080.

Executing a Command #

In addition to running the process(es) the container images were designed for, containers can execute arbitrary commands. For now, let’s run the setup wizard on the web service:

docker compose exec web php craft setup

See the first-time setup guide for more info about what to expect—these are the only things you will need to pay special attention to:

  • Database connection details can be left to their defaults—Craft will automatically discover the variables you added to .env;
  • Your Site URL should be http://localhost:8080—or whatever port you ended up with for the web component;

For existing projects, you may instead want to connect to the db container with a database administration tool and import a backup. (Our craftcms/nginx images do not contain MySQL or Postgres client software, so Craft’s built-in backup and restore commands will not work.)

With Craft installed, head to http://localhost:8080 in your browser.

Bonus Features #

Let’s take a look at some additional Docker Compose features and images that can augment your workflow or better reflect your production infrastructure.

After each change to docker-compose.yml, be sure and restart your application. If you started it with docker compose up, you can press Control + C to exit the process.

Multiple Projects, Port Conflicts, and Hostnames #

One of the things that DDEV simplifies is port collisions when multiple sites are running. On start, it will assign each compose project a pair of ports for HTTP and HTTPs traffic, as well as a variety of other services (like each of your databases). In the normal course of using DDEV, you won't see or care what those ports are, because it handles routing via custom hostnames like my-project.ddev.site.

When using Docker without a routing layer, your services are only accessible via localhost and a port number defined by its configuration. (Services within a compose project can always talk to one another by their service names!)

You may assign ports in docker-compose.yml, or parameterize them via your .env file:

services:
  web:
    image: craftcms/nginx:8.1-dev
    # Load additional environment variables from our .env file:
    env_file: .env
    ports:
      # Use host port 8080 unless one is defined in the .env file:
      - "${DOCKER_COMPOSE_PORT_WEB:-8080}:8080"
DOCKER_COMPOSE_PORT_WEB="8090"

This way, each member of your team (or an outside collaborator) can resolve port conflicts without changing hard-coded values in a tracked configuration file. The same strategy can be used for the database ports:

# ...
ports:
  - "${DOCKER_COMPOSE_PORT_DB:-5432}:5432"
DOCKER_COMPOSE_PORT_DB="6543"

We can't use our existing CRAFT_DB_PORT variable here, because within the Docker network, services talk to one another on their “internal” port rather than the “host” port.

HTTPs #

HTTPs for localhost requires platform-specific configuration that is outside the purview of this guide.

If this is something your application demands (or you are simply curious about), both Traefik Router and Caddy are open-source projects that simplify the process of issuing certificates for private use.

It is worth noting that DDEV provides both SSL and custom domains, without any configuration—and itself uses Traefik!

Xdebug #

Any container that you want to be able to connect to with an Xdebug client must use the *-dev image variants, and define an XDEBUG_CONFIG environment variable:

services:
  web:
    image: craftcms/nginx:8.1-dev
    # ...
    environment:
      XDEBUG_CONFIG: client_host=host.docker.internal

  console:
    image: craftcms/cli:8.1-dev
    # ...
    environment:
      XDEBUG_CONFIG: client_host=host.docker.internal

  # ...

Redis #

Production applications that make use of load balancing or other PaaS products often use Redis to share cache and session data between multiple web nodes, web and worker nodes, or—in cases where the filesystem is ephemeral—provide a persistent storage mechanism between restarts or deployments.

We can add Redis to our Compose project as another service:

services:
  # ...
redis:
    image: redis

Then, follow the installation and configuration instructions in the documentation to point your cache and/or session components to the Redis server. Here’s an example of that configuration applied to the cache component:

use Craft;
use craft\helpers\App;
use yii\redis\Cache;

return [
    # ...
    'components' => [
        # ...
        'cache' => function() {
            $config = [
                'class' => Cache::class,
                'keyPrefix' => Craft::$app->id,
                'defaultDuration' => Craft::$app->config->general->cacheDuration,

                // Full Redis connection details:
                'redis' => [
                    'hostname' => App::env('REDIS_HOSTNAME'),
                    'port' => App::env('REDIS_PORT'),
                ],
            ];

            return Craft::createObject($config);
        },
    ],
];

Your .env file will need two new values, as well:

REDIS_HOSTNAME="redis"
REDIS_PORT="6379"

The Redis port is set by the container image, and cannot be changed, but you may expose it to your host machine under any other port (say, to connect to it with a GUI) using the same strategy described above.

When installing the yii2-redis package, run Composer on the web container with the same exec command we used for the setup process: docker compose exec web vendor/bin/composer require yiisoft/yii2-redis.

This uses the Composer package that was installed when we ran the create-project command, and is the same one that Craft uses for updates, internally!

Health Checks #

Running multiple interdependent services can make it difficult to determine the application’s “readiness” state.

Docker Compose has built-in depends_on and healthcheck features that help us to just this. Update the web service to wait for the database (and Redis, if you’ve configured it):

services:
  web:
    # ...

    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

Then, each service with dependencies declared in this way needs a healthcheck key:

services:
  # ...

  db:
    # ...

    healthcheck:
      test: ["CMD", "pg_isready", "-U", "db", "-d", "db"]
      interval: 5s
      retries: 3

  redis:
    # ...

    healthcheck:
      test: ["CMD", "redis-cli", "ping"]

You may notice a change to the order in which services boot up (or an additional delay before the web service is available and your app responds to HTTP requests).

The interval and retries settings are up to you—depending on the speed of your host machine, you may need more lenient policies.

Mailhog #

The last service we’ll look at is Mailhog, a mock SMTP email server.

Add this service definition to your docker-compose.yml file:

services:
  # ...

  mailhog:
    image: mailhog/mailhog
    ports:
      - 8025:8025

Restart the application, then head to your Craft installation’s SettingsEmail screen:

Craft/Docker: Email Settings

Email settings in Craft, showing the connection details for our new Mailhog server.

Perform a test with these settings; if it’s successful, open up http://localhost:8025 (the Mailhog UI), and you should see your message. If you get an error, double-check the Docker logs to make sure the container is running, or check out our guide on troubleshooting issues with email deliverability.

Keep in mind that any time you open ports (as we did for the Mailhog UI), you may need to negotiate conflicts between running projects.

Craft/Docker: Mailhog

The Mailhog UI, accessible at http://localhost:8025

Applies to Craft CMS 4 and Craft CMS 3.