Skip to main content
Version: v1

Docker

The Docker overview describes the tool as follows:

Docker is an open platform for developing, shipping, and running applications.

Vortex ships a Docker and Docker Compose configuration that runs the project in containers locally, in CI, and on container-based hosting such as Lagoon. A single docker-compose.yml describes the stack for all 3, with a few lines marked as local-only or CI-only.

This page is for developers who change the stack or need to know how it fits together. It covers the following:

  • How to reach the local site and its database from your machine
  • What each service in docker-compose.yml does
  • How code and environment variables reach the containers
  • What the image build leaves out
  • How to check docker-compose.yml after you edit it

Special thanks to Lagoon for providing the container images that make this possible. The Lagoon team maintains these images, which run in production environments and receive regular updates. If you host on Lagoon, your local, CI, and production environments all build the project from the same base images.

About Docker and Docker Compose​

Docker runs each service your project needs, such as a web server or a database server, in its own container. A container is an isolated environment that can still talk to the other containers.

Containers start from images, templates that define what's installed in the container and how it's configured, so every container starts with the same content and configuration.

Docker is the engine that runs those containers: it lets them share the host's resources and talk to each other. Locally, you control it with the Docker CLI, docker.

Docker Compose describes a multi-container application in a single docker-compose.yml file, so all the containers that work together are defined and managed in one place.

Drupal needs several service containers, so you'll mostly use the Docker Compose CLI (docker compose) rather than the Docker CLI. Its commands work on the docker-compose.yml in the current directory, so run them from the project root. They only affect the containers that file defines, not other containers running on your machine.

After a project is set up, day-to-day work comes down to a handful of commands, each scoped to that project:

  • docker compose up -d - start the containers in the background.
  • docker compose down - stop and remove the containers.
  • docker compose exec <service> <command> - run a command in the running <service> container.
  • docker compose logs -f - follow the logs of all the containers.
  • docker compose ps - list the running containers.

You'll need the more advanced commands mainly when you change the project's Docker configuration or services. For anything beyond this overview, see the Docker documentation and the Docker Compose documentation.

Using Docker​

Vortex runs the project in containers both locally and in the continuous integration pipeline. Ahoy wraps the common commands as shorthands, but you can copy any of them into your terminal and run it without Ahoy.

The Environment section covers the commands for each task.

Local site address​

The local site runs at http://<project>.docker.amazee.io, where <project> is the Docker Compose project name. It defaults to the name of the project directory. ahoy info prints the full URL as Site local URL.

To change the project name, and with it the URL, follow these steps:

  1. Run ahoy down to remove the current containers and their database. Docker Compose groups containers by project name, so after the change, Ahoy can't reach the old ones.
  2. Set COMPOSE_PROJECT_NAME in .env.
  3. Run ahoy up to start a new set of containers under the new name.
  4. Run ahoy provision to set up the site in the new containers.

Database connection​

To connect a database client on your machine to the database service, use these details:

  • Host: 127.0.0.1
  • Port: the number that ahoy info prints as DB port on host
  • Database, username, and password: drupal

ahoy info also prints DB host and DB port, but those values work only inside the containers. Docker assigns the host port when the container starts, so it can change after a restart. If the client can't connect, run ahoy info again.

Understanding docker-compose.yml​

Docker Compose reads its configuration from docker-compose.yml and, when it exists, docker-compose.override.yml, which it merges on top. Git ignores the override file, so it's the place for changes that apply only to your machine. Both are YAML files, and YAML supports anchors and references that cut down on repetition.

The file that ships with Vortex has these sections:

Volumes definitions​

Volumes share data between containers. When the host supports volume mounting, your machine shares in too: you edit a file in your editor and the running containers see the change straight away.

That's how local development works here: the x-volumes anchor mounts the code into the cli, nginx, php, and chrome containers. It defines 2 mounts:

  • Project root directory . maps to /app within a container. This is where the PHP engine reads the application code.
  • Files directory ./web/sites/default/files maps to /app/web/sites/default/files within a container, overriding the first mount for that path. A separate mount lets this directory use a different type of syncing to optimize performance, because files don't change as often as the code.

The anchor also carries 2 commented-out mounts for environments without volume mounting support, such as CircleCI, where the containers run on a remote Docker server. Before starting the stack, CI on GitHub Actions and CircleCI alike deletes every line marked ###, which drops the host mounts, and uncomments every line marked ##. That swaps in the named volumes app and files, declared in the top-level volumes section.

That section also declares solr for the Solr index when Solr is enabled, and storybook, a Lagoon persistent volume, when you host on Lagoon with Storybook enabled.

The VOLUME_FLAGS environment variable sets the consistency mode of the host mounts. The values are:

  • default: Equivalent to consistent. Despite its name, it isn't the mode the mounts use when VOLUME_FLAGS is unset.
  • consistent: Full consistency. The container runtime and the host maintain an identical view of the mount at all times.
  • cached: The host's view of the mount is authoritative. There might be delays before updates made on the host are visible within a container.
  • delegated: The container runtime's view of the mount is authoritative. There might be delays before updates made in a container are visible on the host.

When VOLUME_FLAGS is unset or empty, the mounts use delegated, which favors the container's write performance during heavy operations such as installing dependencies. The cost is that changes made in a container take a moment to appear on the host.

To use another mode, set VOLUME_FLAGS in .env and run ahoy up, which recreates the containers with the new mounts.

Default user​

The x-user anchor sets user: '1000', the numeric user ID that container processes run as. The nginx, php, and database services use it, and so does the optional database2. On Linux, files a container writes into a host mount keep that numeric ID. With matching IDs, the files the site writes, such as uploads in web/sites/default/files, belong to you.

If you're on Linux and your user ID isn't 1000 (id -u prints it), change the value in docker-compose.yml, then run ahoy up to recreate the containers that use it. The file is committed, so the change applies to everyone on the project. To change it only on your machine, set user on those services in docker-compose.override.yml instead.

The cli service doesn't use the anchor and runs as root instead. On Linux, files it creates in the project, such as a freshly installed vendor directory, can end up owned by root on your machine. Permission issues has the fix.

Container environment variables​

Docker Compose reads the .env file in the project root and uses its values to fill in the ${VAR} references in docker-compose.yml. A variable already set in your shell takes precedence over the same variable in .env. Compose doesn't pass .env to the containers wholesale, though: it sets only the variables a service lists under environment.

Here, that's the x-environment anchor, which every service except redis and wait-for-dependencies merges in. Compose sets those values when it creates a container, so a change reaches a running container only after ahoy up (or docker compose up -d) recreates it.

The cli, nginx, and php services build on Lagoon images, which load .env themselves. When a container starts, the image's entrypoint reads .env from /app before it launches the main process, and it doesn't override a variable the container already has. In the php container, that process is PHP-FPM, which passes the loaded variables on to every request it handles. That's how Drupal gets the .env variables that docker-compose.yml doesn't list, such as DRUPAL_CLAMAV_ENABLED.

PHP-FPM keeps the values it started with. A change to such a variable doesn't alter the container's configuration, so ahoy up leaves the container running. Run ahoy restart (or docker compose restart) instead, so the entrypoint reads .env again.

Docker Compose and the Lagoon images never read .env.local. Vortex loads it in 2 places, and both keep any variable that's already set in your environment:

  • The Ahoy entrypoint exports .env and then .env.local before every Ahoy command, so ahoy up fills the ${VAR} references with your local overrides. A bare docker compose up -d doesn't see them.
  • Every vendor/bin/vortex-* script loads .env and then .env.local from its working directory when it starts, on the host and inside the containers alike.

For a variable that docker-compose.yml doesn't list, web requests therefore get the value from .env, never from .env.local. To use a value from .env.local, list the variable in docker-compose.override.yml, as Environment variables shows.

Inside the cli container, commands get variables from 2 more sources:

  • ahoy cli, and every Ahoy command built on it, forwards your shell's TERM and LOCALDEV_URL, plus any variable that starts with COMPOSE_, GITHUB_, PACKAGE_, DOCKER_, DRUPAL_, VORTEX_, or ENVIRONMENT_. They reach the command on its next run, with no container recreated.
  • The ~/.bashrc in the Lagoon images reads .env at the start of every bash session, so commands run through bash also see .env variables that docker-compose.yml doesn't list. It doesn't override a variable the container already has, and a process Docker starts directly, such as docker compose exec cli php, skips it.

The x-environment anchor itself holds only 2 kinds of variables:

  • Variables that are specific to the stack and need a default value, such as LAGOON_ROUTE.
  • Variables that can't live in .env and come from the actual environment instead, such as secrets.

Any other variable belongs in .env. In full, the anchor defines:

  • TZ, defaulting to UTC, and the pass-through variables CI, ENVIRONMENT_TYPE, and XDEBUG_ENABLE.
  • LOCALDEV_URL and LAGOON_ROUTE, both set to the local development URL.
  • DATABASE_HOST, DATABASE_NAME, DATABASE_USERNAME, DATABASE_PASSWORD, DATABASE_PORT, DATABASE_CHARSET, and DATABASE_COLLATION, fixed to the values of the local database service.
  • DRUPAL_TRUSTED_HOSTS, DRUPAL_THEME, DRUPAL_PUBLIC_FILES, DRUPAL_PRIVATE_FILES, DRUPAL_TEMPORARY_FILES, and DRUPAL_CONFIG_PATH.
  • Depending on the features you installed, DRUPAL_SHIELD_USER and DRUPAL_SHIELD_PASS (Shield), DRUPAL_REDIS_ENABLED (Redis), and the migration database's DATABASE2_HOST, DATABASE2_NAME, DATABASE2_USERNAME, DATABASE2_PASSWORD, and DATABASE2_PORT.

The nginx service adds 1 pass-through variable of its own, VORTEX_WEBSERVER_MAINTENANCE_MODE, which turns on the static maintenance page.

In CI, the ## marker also switches on SSMTP_MAILHUB, which points outgoing mail at a closed port, so no message leaves a test run. If you host on Lagoon, its environments don't read these values from docker-compose.yml.

Take these 3 lines from the anchor:

TZ: ${TZ:-UTC}
# Local development URL.
LOCALDEV_URL: &default-url ${COMPOSE_PROJECT_NAME:-example-site}.docker.amazee.io
# Local development route used in Lagoon images and Pygmy to route requests.
LAGOON_ROUTE: *default-url

Each of these lines does the following:

  • TZ: ${TZ:-UTC} - takes TZ from your environment or .env, and falls back to UTC when it's unset or empty.
  • LOCALDEV_URL: &default-url ${COMPOSE_PROJECT_NAME:-example-site}.docker.amazee.io - sets LOCALDEV_URL to <project>.docker.amazee.io. The <project> part is the Docker Compose project name, which defaults to the name of the project directory. example-site applies only when the project name is empty. Unlike TZ, it has no fallback to an existing LOCALDEV_URL. The value is also stored in the YAML anchor default-url for later use.
  • LAGOON_ROUTE: *default-url - sets LAGOON_ROUTE to the same value through the *default-url alias, so the 2 always match.

For how Compose handles environment variables in detail, see Set environment variables within your container's environment in the Docker documentation.

Services​

The services section configures each container. The docker-compose.yml that Vortex ships defines the following services. The optional ones are there only when you enable the matching feature in the installer:

  • cli - runs a shell for commands in the context of the project, such as composer or drush, and never handles web requests. The nginx and php builds copy the application code from the cli image, so all 3 contain the same code. The build receives PACKAGE_TOKEN, which you set in .env.local, as the package_token build secret, so Composer can reach private repositories. Compose also passes the token as a build argument on every build, and the Composer step falls back to that argument when the secret is empty. Docker records build argument values in the image history, so the token can end up in the image. On Lagoon, cron jobs run in this container too.
  • nginx - runs the web server, which serves the application and passes requests on to the php container. It's the only service on Pygmy's amazeeio-network.
  • php - runs PHP-FPM, which executes the application code for the requests that nginx passes on. It lacks the command-line tools that the cli image adds, such as Composer, Node.js, and the database client, so it's smaller. In production, it's the container that scales up and down.
  • database - runs the database server that stores the application data. You can reach it from your host on a random port, which ahoy info shows. docker compose port database 3306 prints it too. Set VORTEX_DB_IMAGE to run a pre-built image with the database already inside.
  • database2 - an optional second database container holding the source database for migrations. It's only present when the migration feature is enabled.
  • redis - an optional container that runs a Redis-compatible (Valkey) server, which stores the application cache.
  • solr - an optional container that runs an Apache Solr 9 search server, which stores the application's search index and provides full-text search. It's built from .docker/solr.dockerfile on top of the Lagoon uselagoon/solr-9-drupal image. The configuration set lives in .docker/config/solr/config-set/, so you can customize the Solr schema and keep it under version control. You reach it from the host on a random port: docker compose port solr 8983 prints it, and the Solr admin interface is at http://localhost:<port>/solr/.
  • clamav - an optional container that runs a ClamAV antivirus server, which Drupal uses to scan uploaded files for viruses.
  • chrome - runs Chromium, the open source browser that Chrome is built from, for the Behat tests. It's based on the official Selenium image, which comes with the extra tools a browser needs in a container, such as a virtual desktop. ahoy info prints a VNC URL where you can watch that desktop while the tests run.
  • wait-for-dependencies - runs a script that waits for the applications in other containers to become available. Docker Compose starts containers in dependency order, but a container that has started isn't always ready to accept connections. This container polls the database port and, when they're present, the clamav, database2, redis, and solr ports, plus a Solr query against the drupal core. The cli service waits for it to exit with a zero exit code, so the stack is ready to work with after it exits.

Networks​

The networks section declares amazeeio-network, an external network that Pygmy creates. Only nginx joins it, next to the project's default network, so Pygmy's proxy can route requests for the local URL to the web server. The other services use only the default network that Docker Compose creates for the project, which is how the containers reach each other by service name, such as database or redis. CI deletes the amazeeio-network lines, which are marked ###, because it doesn't route requests through Pygmy.

PHP configuration​

The cli and php builds copy custom.ini from the service's directory in .docker/config/ into PHP's conf.d directory. Set PHP ini directives for a service in its file:

ServiceFileApplies to
cli.docker/config/cli/custom.iniCommands that run in the container, such as Composer and Drush
php.docker/config/php/custom.iniWeb requests that PHP-FPM serves

PHP reads conf.d in alphabetical order, so custom.ini loads after Lagoon's 00-lagoon-php.ini and overrides its defaults. Those defaults include the values of PHP_* environment variables, such as PHP_MEMORY_LIMIT. The php file ships with session upload progress turned off: see PHP settings.

In the cli container, PHP loads drush/php-ini/drush.ini last, so a directive set in both files takes its drush.ini value.

The images include custom.ini at build time, so after you change it, rebuild the images and recreate the containers with ahoy up --build.

Build context and .dockerignore​

When Docker builds an image, it sends a build context, the set of files the build can copy into the image, to the build engine. Vortex controls this context with a .dockerignore file written as a deny list: every file is part of the context by default, and the file lists only what must stay out.

The file excludes the following from the image build context:

  • Version control, IDE, and operating system files (.git, .idea, .vscode, **/.DS_Store).
  • Credentials and local overrides (auth*, .env.local, .ahoy.local.yml, docker-compose.override.yml, web/sites/*/settings.local.php, web/sites/*/services.local.yml). The package token reaches the build through a build secret and a build argument instead.
  • Host-installed dependencies, which are reinstalled during the build (vendor, node_modules, web/themes/**/node_modules).
  • Composer-generated Drupal directories, which are regenerated during the build (web/core, web/libraries, web/modules/contrib, web/profiles/contrib, web/themes/contrib, drush/Commands/contrib).
  • Content files, test artifacts, caches, logs, and compiled theme assets (web/sites/*/files, web/sites/simpletest, **/.artifacts, **/.data, **/.logs, **/.phpunit.cache, **/.twig-cs-fixer.cache, web/themes/**/build).
  • When the matching features are enabled, the Storybook output (web/themes/**/*.stories.json, web/themes/**/storybook-static) and the database cache key files that the GitHub Actions workflow writes (db_cache_*).

Keeping these out of the context matters for security, because secrets and database dumps never reach an image layer, and for speed, because a smaller context builds faster. It matters for correctness too: the CLI build copies the whole context into /app after its own composer install, so a host vendor directory would overwrite the freshly installed one.

Everything not listed, such as application code, configuration, composer.json, and settings files, is included automatically, so a new file you add reaches the build without any change to .dockerignore. That includes .env, which the CLI image copies in so that Composer scripts can read the project's variables.

Validate docker-compose.yml​

After editing docker-compose.yml, validate it before you run a build. This command parses the file, resolves its variables and anchors, and prints the result, or the error that stopped it:

docker compose -f docker-compose.yml config