DockerFile Annotated Example

Lessons & Guidelines Learned

I spent a fair amount of time last year on a pristine and robust Docker file, and while many of these have been shared, there seem few really well-annotated and explained files. So here we go …

The service we are building here is a PHP / Laravel application served by Apache, all on Alpine. So it’s more complex than most services, and thus therein lies some lessons & practices — of course, your language and environment may differ.

It’s also based on Alpine, a popular lightweight Linux OS that is very easy and cool to use, but not without its small challenges.

Without further ado, here is the start of our file:

These first lines took quite a while to work out correctly, and are important to enable Docker’s experimental features. In this case, I wanted to use some experimental secrets passing as mounts. This syntax ‘comment’ must be on the first line, which is annoying as normally we’d put an upfront name and details there.

Some titles and info on what we are doing, how this will be used, etc.

Set your TODO list, in this case, things we want to do over time, including more features like health checks and stop signals, plus non-root users.

ARG BUILD_ENV

I didn’t use build-time arguments, but they need to be carefully placed and it’s good to have a section for them for later use. Notice the note about changing things early in the file invalidates your build cache.

FROM php:7.3.16-alpine

The all-important FROM section, with any notes, in this why we’re using a specific base version, so a future developer doesn’t change this randomly.

ARG TARGETPLATFORM

Several things in the file must be in a certain order, so we note that and include them here even though we’re not really using them.

# ONBUILD

Another section we include for future use.

LABEL org.opencontainers.image.maintainer="Steve.Mushero@ELKman.io"     \      org.opencontainers.image.authors="Steve.Mushero@ELKman.io"        \      org.opencontainers.image.title="ELKman"                           \      org.opencontainers.image.vendor="Wapiti Systms Inc."              \      org.opencontainers.image.url="https://www.elkman.io"              \      org.opencontainers.image.documentation="https://elkman.io"
\ org.opencontainers.image.description="ELK Management"
\ # SPDX License Expression format;
\ # UNLICENSED is used for private licenses
\ org.opencontainers.image.licenses="UNLICENSED" \ org.opencontainers.image.version="${ELKMAN_VERSION}"
\ org.opencontainers.image.version="RELEASE"

# org.opencontainers.image.revision="" FROM git
# org.opencontainers.image.created="2020-05-01T01:01:01.01Z"

The Open Containers initiative is interesting, so I added basic info here for that.

It’s a good idea to list key assumptions and defaults from the base container, especially about directories, users, etc. so we can be sure we are doing the right things in the right places in this file — more complex base containers will have more of these, which can easily confuse later builders.

The secrets mount system was an experimental feature at that time, though we ended unable to use it for some runtime and 3rd party tooling reasons.

Here we set the various users and paths that we’ll be using — be sure to set these as ENV and not hard-code further down in the file, which makes future changes very difficult.

I always build a list of core OS tools I want to install on top of the base, though some can be removed later after initial testing & troubleshooting. Alphabetical order helps maintain sanity as this changes. This list as part of a variable INSTALL_TOOLS is far cleaner & better than putting them inline or in many install lines below.

Some notes on how we can do multi-service things as needed. In this case, we’re using Apache with modPHP so we don’t need things like Nginx and php-FPM, plus multi-process is harder on Alpine, as noted.

Some notes for developers on using apk, including how we can minimize things.

RUN apk update --no-cache && apk add --no-cache --clean-protected ${INSTALL_TOOLS}

Our first install step, noting the important ‘update’ and no-cache options. I think these can be put in their own ENV variables if needed, for consistency.

# ENV EXTRA_PACKAGES sqlite3
RUN apk update --no-cache && apk add --no-cache ${EXTRA_PACKAGES}

Define extra things that are not really OS-related, like sqlite, and install them. Separating them from the OS is much cleaner and easier to maintain.

# ENV REMOVE_PACKAGES xx
# RUN apk del $(REMOVE_PACKAGES)

Most base images have things we can remove for space savings, assuming we are doing multi-stage or building with the squash option.

It’s good to mark your sections to keep the file organized, to find things easily, and to keep future pieces from being added randomly in the wrong places.

ENV PHP_PACKAGES php7 php7-apache2 php7-json php7-phar php7-iconv \
php7-openssl php7-curl php7-mbstring php7-fileinfo \
php7-tokenizer php7-dom php7-session php7-pdo php7-pdo_sqlite \
php7-xml php7-simplexml php7-xmlwriter php7-zip
RUN apk update --no-cache && apk add --no-cache --clean-protected ${PHP_PACKAGES}

Starting our long Apache section, first with the packages we need to install.

ENV APACHECONFFILE /etc/apache2/httpd.conf
ENV APACHECONFDDIR /etc/apache2/conf.d
ENV APACHEVHOSTCONFFILE ${APACHECONFDDIR}/default.conf
ENV APACHESECURITYFILE ${APACHECONFDDIR}/security.conf
# Copy over PHP file from PHP-Apache
# Skipping as seems the Alpine version has one: php7-module.conf
# COPY /deploy/apache/docker-php.conf ${APACHECONFDDIR}/docker-php.conf
RUN echo && \
# Remove stuff we don't want nor need for security, etc.
rm /etc/apache2/conf.d/userdir.conf && \
rm /etc/apache2/conf.d/info.conf && \
#
# Apache main config overrides
#
sed -ri -e 's/^#ServerName.*$/ServerName elkman/g' ${APACHECONFFILE} && \
sed -ri -e 's/^ServerTokens.*$/ServerTokens Prod/g' ${APACHECONFFILE} && \
sed -ri -e 's/^ServerSignature.*$/ServerSignature Off/g' ${APACHECONFFILE} && \
#
# Need to allow .htaccess overrides for Laravel, it rewrites paths to use index.php
#
sed -ri -e 's/AllowOverride None.*$/AllowOverride All/g' ${APACHECONFFILE} && \
sed -ri -e 's/\/var\/www\/localhost\/htdocs/\/var\/www\/public/g' ${APACHECONFFILE} && \
sed -ri -e 's/^#LoadModule rewrite_module modules\/mod_rewrite.so/LoadModule rewrite_module modules\/mod_rewrite.so/g' ${APACHECONFFILE} && \
#
# Security Overrides (creating new file)
#
echo 'Header set X-Content-Type-Options: "nosniff" ' > ${APACHESECURITYFILE} && \
echo 'Header set X-Frame-Options: "sameorigin" ' >> ${APACHESECURITYFILE}

There are many ways to set config files. In this case, we want to retain nearly all the defaults, so rather than make copies of the base files, we just edit them in place to adjust a few things.

Basically, we set the variables then run sed to make changes in the various files. Note in the first part we used to also just copy over an artifact file to start with, but later moved to using the included base image file.

#   Then the one we want works fine in path
RUN mv /usr/local/bin/php /usr/local/bin/php.bad
# For Alphine 7.3 we use /usr/bin/php and /usr/etc/phpENV PHP_INI_DIR /etc/php7
ENV PHPEXTDIR "/usr/lib/php7/modules/"
# Use the default prod configuration from php:7.4.4-apache (php.ini-development also exists)
COPY deploy/php/php.ini-production $PHP_INI_DIR/php.ini
# Copy overrides
COPY deploy/php/php-override-prod.ini $PHP_INI_DIR/conf.d/
COPY deploy/php/php-sourceguardian.ini $PHP_INI_DIR/conf.d/
# Copy Source Guardian run-time Loader
COPY deploy/php/ixed.7.3.lin ${PHPEXTDIR}

# Install composer & prestissimo for parallel downloads if needed
RUN curl -sS https://getcomposer.org/installer | \
php -- --install-dir=/usr/local/bin --filename=composer && \
composer global require hirak/prestissimo --no-plugins --no-scripts

Here is the complicated PHP setup, which has lots of details of copying and updating settings files, setting versions, and installing source code protection libraries. It takes lots of trial & error to get this right, so it’s important to keep the section organized and documented.

RUN apk update --no-cache && apk add --no-cache --clean-protected npm

Installing NPM is fortunately nice and simple.

WORKDIR ${MAINWORKDIR}

# Copy files from VM
# Copy App Directories - Not setting owners here, it's done later
# Note will ignore the .dockerignore things, so tune that, too
# Currently we depend on git to create/ignore all the dirs we need, especially in storage
# We do this because later we want to git clone into container as part of build
COPY app app
COPY config config
COPY resources resources
COPY routes routes
COPY bootstrap bootstrap
COPY database database
COPY storage storage
COPY public public
COPY tests tests
# Copy Specific Files
COPY artisan ./
COPY composer.json ./
COPY composer.lock ./
COPY package.json ./
COPY package-lock.json ./
COPY webpack.mix.js ./

Now we add our code, in this case from the build environment. You can also pull from git, install as a package, etc. but our build environment already has pulled all the code, artifacts, build scripts, docker file, etc. so coping is easiest.

The COPY commands are very specific and the result of lots of testing. Note also the notes on .dockerignore, permissions, etc. as this has to be consistent and well-understood.

#   ENV COMPOSER_CACHE_DIR - Can set if needed, now using default#   Cannot use RUN mount here as we need a cache dir, and mount only supports files (as far as I can tell)#   Copy in composer cache, use and remove

COPY /composer-cache/files /root/.composer/cache/files
# Note: Have to run 'composer dump-autoload' for some reason here; seems install not fully doing itRUN composer install --no-dev --classmap-authoritative --no-ansi \
--no-scripts --no-interaction --no-suggest && \
composer dump-autoload && \
rm -rf /root/.composer/cache

This PHP composer section, which we run inside the container build process to get & set all the right libraries. This is messy, and we also use a prior cache for performance, though this was the result of a lot of testing, trial, and error.

RUN npm ci --no-optional
RUN npm run prod

Run the npm pieces to build our Javascript stuff, run webpack, etc.

An example of going one way then another as we get the rest way to do things. Managing Javascript things is especially challenging.

RUN touch storage/database/db.sqlite

We have a local database and sqlite requires the file to be in place for the first run, so for now we just touch it, with other options to copy in a default, too.

COPY .env.production .env

# Copy dusk env for now for testing
COPY .env.dusk.testing .env.dusk.testing

Copy our environment files in, separate from code. We could also set specific options here if we needed to, but this app can use a standard static config. Note for dev/testing, we’d use other files here or make them more dynamic at run-time.

# Generate a new key each time (though we also need on install)RUN php artisan key:generate# Optimize & cache; do before we migrate or run other artisan jobsRUN php artisan optimize# Seed tables, Telescope, etc. data into DB
# Run after keygen, before other artisan cmds
RUN php artisan migrate# Update DB version to app code version; this for container's initial DB onlyRUN php artisan elkman:update

Run a bunch of Laravel commands to setup PHP configs, keys, and build the starting database structures. This kind of thing changes a lot, so it’s important to include good comments.

Clean up the logs from all that, both so we start clean and to reduce space. Always remember to purge any logs created during the build process (in part as they may have stuff you don’t want users or customers to see).

ENV READPERM 440
ENV WRITEPERM 660
RUN chown -R ${MAINUSER}:${APACHEGROUP} ./ && \
chmod -R ${READPERM} ./ && \
chmod -R ${WRITEPERM} storage && \
# Set all dirs to be executable so we can get into them
# Do after any chmods above
find ./ -type d -print0 | xargs -0 chmod ug+x

Laravel permissions can be messy, and take a lot of testing, so we carefully do them here all at once.

RUN rm -rf /var/cache/apk/*

Clean up more temp caches to reduce size.

EXPOSE 80/TCP

# Careful to separate each argument, e.g. like "-f" and the file:
CMD ["/usr/sbin/httpd", "-f", "/etc/apache2/httpd.conf", "-DFOREGROUND"]

# Run command:
# docker run -d -p 8000:80 elkmanio/elkman:latest

Finally, we have the run-time Docker details. We first repeat any defaults from the base image so we know what it sets and we might need to override. And a final note on how to actually run this container, as a reminder, especially if there are special ports or options involved.

That’s it. A good and fairly Docker file by example.

CEO of ChinaNetCloud & Siglos.io — Global Entrepreneur in Shanghai & Silicon Valley

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store