How to Use Docker Secrets in PHP Apps

How to Use Docker Secrets in PHP Apps

Docker Secrets are a good way to start storing sensitive data that your PHP apps need in an organised and structured way. You don’t need to use environment variables, and you continue avoiding them being stored in code. In this tutorial, I’ll show you how to use them, and consider some of their pros and cons.


Prerequisites

Before we continue, make sure you have the following if you want to actively follow along with the tutorial:

  • Docker Compose 2.30.0 or higher
  • Experience with writing or editing Dockerfiles
  • Experience with building Docker Compose configurations
  • Some shell scripting experience, specifically knowledge of Bash
  • Some PHP experience

What are Docker Secrets?

First up, what is a secret? The Docker documentation describes them as follows:

A secret is any piece of data, such as a password, certificate, or API key, that shouldn’t be transmitted over a network or stored unencrypted in a Dockerfile or in your application’s source code.

So, what is a Docker Secret? The same section of the documentation describes them as:

…a way for you to use [the aforementioned] secrets without having to use environment variables to store information. Services [containers] can only access secrets when explicitly granted by a secrets attribute within the services top-level element.

Why shouldn’t you use environment variables to store sensitive data?

Well, while they’re handy, they’re not the best choice for storing sensitive data. Why?

  1. Because environment variables are available to all processes.
  2. It’s quite difficult to track who accesses them.
  3. They can also end up in your logs

In modern software, especially PHP, it’s quite common to store information that apps need in environment variables – outside of your PHP code. That’s something that I actively encourage in my Twilio tutorials.

Why? Because they simplify providing configuration details to your application, configuration details that will be different between environments (e.g., prod and staging), as well as for each developer when developing locally. They (almost) force you to keep configuration data outside of code. You don’t want to store configuration data in code as you never know where it might end up – or who might see it. And, you don’t want to accidentally store this information under version control either.

Imagine if you accidentally committed a change that included your database password. It could be quite challenging to purge that commit.

If you did do this, among other things, your production servers may need to be updated to avoid a future security breach. And, do you want to be the one telling your sys admin that they have to do that? I sure know that I wouldn’t want to have that conversation.

So, you keep config and sensitive data out of your code. Gladly, it’s so easy to do this when working with Docker Compose and when using the excellent PHP Dotenv package, as I’ll demonstrate in a moment.

How do you use environment variables with Docker Compose?

Let’s say that your PHP app uses PostgreSQL as its backend data store. Given that, you’ll need to store the database’s name, hostname, username, and password. Here’s, a simplistic Docker Compose configuration that would get you started:

services:

  php:
    image: php:8.4-rc-apache-bookworm
    ports:
      - "8080:80"
    environment:
      DB_HOST: "${DB_HOST}"
      DB_NAME: "${DB_NAME}"
      DB_PASSWORD: "${DB_PASSWORD}"
      DB_USER: "${DB_USER}"

  database:
    image: postgres:17.4-alpine3.21
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: "${DB_NAME}"
      POSTGRES_USER: "${DB_USER}"
      POSTGRES_PASSWORD: "${DB_PASSWORD}"

If you want to follow along, create a new directory wherever you store your PHP apps, and in that directory create a new file named compose.yml. Then, paste the configuration above into that file.

The configuration defines two services:

The “php” service:

  • Maps port 8080 on the host machine (your local development machine) to port 80 in the container
  • Defines four environment variables which are initialised with the value of local environment variables of the same name in the host machine.

The “database” service:

  • Maps port 5432 (PostgreSQL’s default port) on the host machine to port 5432 in the container.
  • Sets three environment variables: POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD. These are set, respectively, by the environment variables DB_NAME, DB_USER, and DB_PASSWORD in the host machine’s shell. In development, DB_NAME, DB_USER, and DB_PASSWORD would most likely be defined in .env in the same directory as your Docker Compose configuration file (compose.yml).

To continue following along, create a file named .env in the top-level directory of the project as follows:

DB_HOST=database
DB_NAME=your_database
DB_PASSWORD=password
DB_USER=user

When Docker Compose starts the containers, it will load the variables in .env, setting the environment variables defined in compose.yml for the containers.

Note: You could set the environment variables directly in compose.yml. But, it’s easier and more flexible to read them from the environment variables defined in the environment that starts the container.

Now, somewhere in your PHP application, early in the request/boot process, you would use PHP Dotenv to load the environment variables into PHP’s $_SERVER and $_ENV superglobals, with the following code:

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/');
$dotenv->load();

As a result, your PHP code has access to the database’s configuration settings, while letting you keep the configuration data out of code. Doing this makes your application more flexible and easier to configure.

For example, using the Docker Compose configuration that we’ve been working with so far, if you had to change the hostname, port number, or database name, you’d just change these values in one place. You don’t need to grok the code to find and then update them.

But, environment variables are not for sensitive data!

Despite the flexibility of environment variables, it’s all too easy to use them to store sensitive information as well. However, if you do, anyone and any process with access to the container (or the host machine) can see your database’s password.

For example, a user on the host machine, or who could get access to it, could use the docker inspect or docker compose exec commands to print the value of the environment variable, as in the following examples.

docker inspect mezzio-order-tracker-php-1 | jq '.[].Config.Env'

If you’re not familiar with it, the Docker Inspect command retrieves low-level information on Docker objects, such as containers. Included in this information, among other things, are the container’s environment variables.

As the command’s output is printed in JSON format by default, the example above pipes the output of docker inspect to jq, a JSON processor, filtering out everything but the environment variables.

So, if you were using the earlier Docker Compose configuration, you could expect to see output similar to the following:

[
  "APACHE_CONFDIR=/etc/apache2",
  "APACHE_DOCUMENT_ROOT=/var/www/html/public",
  "APACHE_ENVVARS=/etc/apache2/envvars",
  "DB_HOST=localhost",
  "DB_NAME=your_database",
  "DB_PASSWORD=password",
  "DB_USER=user",
  "HOSTNAME=c55a993ec65d"
  "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "PHPIZE_DEPS=autoconf \t\tdpkg-dev \t\tfile \t\tg++ \t\tgcc \t\tlibc-dev \t\tmake \t\tpkg-config \t\tre2c",
  "PHP_ASC_URL=https://downloads.php.net/~calvinb/php-8.4.6RC1.tar.xz.asc",
  "PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64",
  "PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64",
  "PHP_INI_DIR=/usr/local/etc/php",
  "PHP_LDFLAGS=-Wl,-O1 -pie",
  "PHP_SHA256=1629a83fd89ff14e22ecc9f1a8cebfbf28c82382fff2efd0ab16c3fff59de2b7",
  "PHP_URL=https://downloads.php.net/~calvinb/php-8.4.6RC1.tar.xz",
  "PHP_VERSION=8.4.6RC1",
]

See the problem? The database password is there in plain text on the seventh line.

docker compose exec, such as in the following example, would be just as revealing:

docker compose exec php /bin/bash -c 'echo $DB_PASSWORD'

So, storing sensitive data in environment variables is NOT secure. Just to stress, this likely isn’t an issue in development, but otherwise it will be.

Given that, you want to use a secrets manager such as Docker Secrets, to avoid the information ever leaking in the first place. By doing so, as the earlier quote showed, avoids storing sensitive data in environment variables, and transmitting them over the network. Not only that, but:

A given secret is only accessible to those services which have been granted explicit access to it, and only while those service tasks are running.

How do you configure Docker Secrets?

There’s not much to it. All you need to do is to extend the existing Docker Compose configuration that we created earlier in two ways.

First, add a top-level secrets element. This defines the secret that you want to store and how its value will be set. Take the following example:

secrets:
  db_password:
    environment: DB_PASSWORD

This defines a Docker Secret named db_password that is initialised with the value of an environment variable set in the host’s shell named DB_PASSWORD.

You could also initialise the secret from the value of a file, such as in the following example:

secrets:
  db_password:
    file: ./db_password.txt

In this case, we’d need to create a file named db_password.txt in the same directory as compose.yml and paste the password’s value into it.

Then, we’d update the services element of compose.yml as follows, to use the newly defined secret:

services:

  php:
    image: php:8.4-rc-apache-bookworm
    ports:
      - "8080:80"
    environment:
      DB_HOST: "${DB_HOST}"
      DB_NAME: "${DB_NAME}"
      DB_PASSWORD: /run/secrets/db_password
      DB_USER: "${DB_USER}"
    secrets:
      - db_password

  database:
    image: postgres:17.4-alpine3.21
    ports:
      - "5432:5432"
   environment:
      POSTGRES_DB: "${DB_NAME}"
      POSTGRES_USER: "${DB_USER}"
      POSTGRES_PASSWORD: /run/secrets/db_password
  secrets:
    - db_password

With this configuration, when Docker starts the containers, it will create a temporary filesystem bind mounted under /run/secrets/db_password with the value provided either in the environment variable or local file.

If this is your first time hearing about bind mounts:

When you use a bind mount, a file or directory on the host machine is mounted from the host into a container.

What this means is that the file is only available to the container while it is running. It will never be part of the underlying image. So, it makes it pretty hard for the secret to ever be stored in the image, accidentally or otherwise.

Is Docker Secrets more secure than using environment variables?

Actually, after spending a good bit of time with Docker Secrets I find the documentation to be more than a little misleading. While the secrets files in /run/secrets are owned by root, which might normally make them impossible to read by other users, they’re readable by everyone, as the following shell output example shows:

ls -lh /run/secrets/
total 12K
-r--r--r-- 1 root root    8 Apr  4 11:15 db_password

You can see that the owner (root), group (root), and anyone else can read the file. What’s more, if you view the permissions on the containing directory (/run/secrets), while root is the only user that can write to the directory (and no one can write to the file), anyone can navigate into the directory:

ls -lh /run/ | grep secrets
drwxr-xr-x 2 root     root     4.0K Apr  4 11:15 secrets

Now, let’s consider some other, seemingly glaring issues. If you initialise a secret from an environment variable, that environment variable is accessible on the host system. And, if you initialise a secret from a file, that file is accessible to the host system. In both cases, you’d have to take care as to which user started the containers, and ensure that either:

  • The file and containing directory permissions were suitably restrictive
  • The environment variable was set in as secure a manner as possible

Initialising Secrets with a roll-your-own approach is outside the scope of this tutorial. But, if you want to set Docker Secrets as securely as possible, check out solutions such as phase.

Alternatively, you could use a tool such as sops to initialise the secret from an encrypted file. Again, doing so is outside the scope of this tutorial. But by using it, the secret would be unencrypted and passed in-memory to Docker Compose, bypassing any way of storing, logging, or tracing it in-the-clear on the host machine.

Getting access to the secret from the host with Docker Compose exec still applies. However, by following Docker best practices and security best practices should help to limit the ability to do that, or to use any other tool to expose it.

And, kind of frustratingly, it’s up to your application code to use secrets properly. So, make sure you follow guides such as the OWASP PHP Configuration Cheat Sheet.

Is Docker Secrets worth using?

Despite some of the highlighted shortcomings, I still believe that Docker Secrets is worth considering because:

  • It offers you a clear, well-documented, separate, and methodically organised way to store the sensitive data which your PHP applications need
  • Sensitive data is kept outside of code and away from other configuration data
  • It encourages you to pay more attention to how you handle sensitive data; something that you can build upon with time

In working this way, you approach application design with an ever more security-focused approach, significantly reducing the chances of sensitive data ever being stored in version control or being accidentally leaked to anyone or anywhere.

If you’re a Docker Swarm user (though I believe, while passionate, that community is slowly fading), Docker Secrets are much better implemented as, because:

Secrets are encrypted during transit and at rest in a Docker swarm

How do you access a Docker Secret in PHP?

In our earlier example, we set DB_PASSWORD via a Docker Secret. Problem solved. We can now retrieve the secret be retrieving the value of $_ENV['DB_PASSWORD'] (or similar) right? Wrong.

Using our example Docker Compose configuration, DB_PASSWORD won’t have the actual value stored in /run/secrets/db_password. Rather, /run/secrets/db_password is the variable’s value. So, your PHP code will have to be aware that and retrieve the file’s contents.

Alternatively, you could augment your Docker Compose configuration to initialise an additional environment variable with the secret’s value, like the official Docker Hub PostgreSQL image does.

Let me explain. If an environment variable is defined that ends in _FILE, the PostgreSQL container assumes that that variable was initialised with a Docker Secret. So, it initialises a new environment variable with the same name, minus the _FILE suffix, with the value of the secret file’s contents.

So, in our configuration, if we included POSTGRES_PASSWORD_FILE: /run/secrets/db_password in the environment variables of the database service above, during container start another environment variable, named POSTGRES_PASSWORD, would be created that contained the contents of /run/secrets/db_password.

Check out the ENTRYPOINT script in the image’s GitHub repository if you’d like to see how it works in its entirety.

Let’s do the same for DB_PASSWORD in our php container. In a new file in your project, you’d create a file named something like docker-entrypoint and add the code below to the file.

#!/usr/bin/env bash

set -Eeo pipefail

file_env() {
  local var="$1"
  local fileVar="${var}_FILE"

  if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
    printf >&2 'error: both %s and %s are set\n' "$var" "$fileVar"
    exit 1
  fi

  if [ "${!fileVar:-}" ]; then
    val="$(< "${!fileVar}")"
  fi

  export "$var"="$val"
  unset "$fileVar"
}

_main() {
  file_env DB_PASSWORD

  if [ "${1#-}" != "$1" ]; then
    set -- apache2-foreground "$@"
  fi

  exec "$@"
}

_main "$@"

The script defines a function named file_env which takes the name of an environment variable to initialise. It then attempts to create an environment variable with the same name as the one provided and the suffix _FILE. If these two variables already exist, then the script exits.

Otherwise, the new variable is initialised with the value of the _FILE version, then the variable is exported. This final step is necessary as, up until this point, the new variable’s scope is limited to the current script. So, when the script exits, the environment variable will no longer exist. Exporting a shell script variable sets it in the calling shell’s environment.

Then, a second function (_main) is defined. This function calls the file_env function, passing DB_PASSWORD to it. As we updated compose.yml to set an environment variable named DB_PASSWORD_FILE, which will be set to the Docker Secret file of db_password, storing the value password, then DB_PASSWORD will be created an initialised with the value password.

After that, as the php container is based on the official Docker Hub PHP image, specifically the Apache 2 variant, the script then launches apache2 in the foreground, so that it can listen for requests and pass them to PHP.

Now, there’s one more thing to go, which is to update the php image’s Dockerfile so that it uses the new ENTRYPOINT script instead of the base image’s existing one. So, in the Dockerfile, add the following to the end of the file.

COPY ./docker/php/docker-entrypoint /usr/local/bin/

ENTRYPOINT ["docker-entrypoint"]

CMD ["apache2-foreground"]

This ensures that the docker-entrypoint script is copied into /usr/local/bin in the image; avoiding the need to set execute permissions on the file or explicitly add it to the user’s path. Then, docker-entrypoint is set as the image’s ENTRYPOINT script.

Now, when the container is started with our custom image, a new environment variable named DB_PASSWORD_FILE will be available, which PHP can use to authenticate with the PostgreSQL database.

One other drawback of Docker Secrets

While Docker Secrets are a good, structured way of storing sensitive information, there are drawbacks (at least as I understand it at the moment). The key one that I’ve encountered is using command line tooling, such as Doctrine Migrations.

As, when using the Docker Compose Exec command, you don’t get access – directly – to the running environment within the container that has the environment variables set. Given that, you can’t use tooling that requires variables that need to use Docker Secrets, such as a database password for Doctrine Migrations. Well, not directly.

That said, with a little bit of shell magic, such as with the following command, you can get around the issue:

docker compose exec \
    php /bin/bash \
    -c 'DB_PASSWORD=$(</run/secrets/db_password) composer mezzio doctrine:migrations:migrate

If you’re not familiar with what’s going on, the command wrapped in single quotes is run inside the container. It:

  • Declares a new variable named DB_PASSWORD setting it with the contents of /run/secrets/db_password
  • Uses Composer to run Doctrine Migrations, which would run any migrations on the configured database, should any need to be run.

That’s the essentials of using Docker Secrets with PHP apps

It’s an excellent way of starting to store sensitive data that your PHP apps need – without using environment variables, and avoiding the information being stored in code. It’s not a perfect solution, and there are some clear drawbacks. But it’s an excellent first step along the road of providing sensitive information to your PHP applications.

Do you need to get your head around Docker Compose quickly?

What about needing to dockerize existing applications to make them easier to deploy, reducing the time required for developers to get started on projects, or learning how to debug an existing Docker Compose-based app? Then this free book is for you!

You might also be interested in these tutorials too...

Dockerfile build arguments go out of scope
Fri, Jun 14, 2024

Dockerfile build arguments go out of scope

When you’re writing Dockerfiles using build arguments (which you should) it’s important to keep their scope in mind. Otherwise, you’ll get very frustrated (more than likely).

Validate Dockerfiles With One Command
Tue, Aug 27, 2024

Validate Dockerfiles With One Command

Docker is an excellent way of deploying software. But, how do you know if your build configurations (your Dockerfiles) are valid without building them? In this short tutorial, I’ll show you how.

Setup Step Debugging in PHP with Xdebug 3 and Docker Compose
Wed, Mar 10, 2021

Setup Step Debugging in PHP with Xdebug 3 and Docker Compose

In versions of Xdebug before version 3 setting up step debugging for code inside Docker containers has often been challenging to say the least. However, in version 3 it’s become almost trivial. In this short tutorial, I’ll step you through what you need to do, regardless of the (supported) text editor or IDE you’re using.

How Do You Use CSRF Tokens in a Mezzio Application?
Tue, Mar 2, 2021

How Do You Use CSRF Tokens in a Mezzio Application?

No matter how small your web app may be, security is essential! In this tutorial, you’ll learn how to add a CSRF token in forms used in Mezzio-based applications, to prevent attackers from being able to force your users to execute malicious actions.


Want more tutorials like this?

If so, enter your email address in the field below and click subscribe.

You can unsubscribe at any time by clicking the link in the footer of the emails you'll receive. Here's my privacy policy, if you'd like to know more. I use Mailchimp to send emails. You can learn more about their privacy practices here.

Join the discussion

comments powered by Disqus