
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).
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.
Before we continue, make sure you have the following if you want to actively follow along with the tutorial:
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 theservices
top-level element.
Well, while they’re handy, they’re not the best choice for storing sensitive data. Why?
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.
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:
The “database” service:
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.
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.
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.
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:
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.
Despite some of the highlighted shortcomings, I still believe that Docker Secrets is worth considering because:
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
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.
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:
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.
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).
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.
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.
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.
Please consider buying me a coffee. It really helps me to keep producing new tutorials.
Join the discussion
comments powered by Disqus