Sarah Ting

Laravel/PHP/nginx Alpine Dockerfile

🎯 Goal

I’m currently working on moving an older Laravel/Apache app from bare metal installations into Docker containers, and am taking the opportunity to also see how it fares with nginx.

For the move, I’m expecting to need four different images at any given time —

  • sail (local development with Laravel Sail; I’ve opted to build this based off of the production Dockerfile to bring it as close as possible to the production set up)
  • web (php8.1-fpm + nginx, with plans to upgrade after the migration)
  • jobs (Laravel Horizon)
  • scheduler (cron jobs)

In my last company, we had a separate docker repository for each service (I wonder if this makes CI/CD easier?), but since these all use the same codebase and share a large number of image layers, I’ve decided to try make these all separated tags on the same repository. I’ve opted to have one sail tag at any given time, but a web jobs and scheduler tag for every release. For example —

  • sail (I may split this up by Laravel or PHP version, eg. sail-8.1 and sail-8.2)
  • web-1, web-2, web-3, web-4, etc (where the suffix is the release number)
  • jobs-1, jobs-2, jobs-3, jobs-4
  • scheduler-1, scheduler-2, scheduler-3, scheduler-4

I’m not completely sold on this; I’ve seen other implementations of a Laravel codebase that just combine everything into a single Docker image and swaps between app/jobs/scheduler behaviours using an environment flag on the entrypoint script. This seems like it would ease deployment (it means I only have to release a 1 image instead of 3 different images), but it also means each image contains unnecessary packages.

For example, the web container requires nginx and JavaScript build files (that node_modules folder gets pretty heavy!!), whereas jobs and scheduler do not. scheduler is the only image that needs supercronic. The health check for each image is also different.

I opted for separate images with this reasoning, but it’s entirely possible I’ll change my mind later.


🤝 Combining php-fpm + nginx?

Docker best practices recommend one process per container. When I originally started this project I tried splitting up php-fpm and nginx into two separate images, but ran into issues which made me question whether this was the right choice (namely, how to share the PHP codebase’s static files with nginx without serving them through PHP).

I checked a few resources, including asking the official Laravel Discord and watching through an online course Shipping Docker (from Servers for Hackers). I received the same advice: just package PHP and nginx together in the same image, then place an additional layer of nginx/caddy/other load balancer in front to handle load balancing and HTTPS.

To my understanding this is pretty common practice — I’m still a little worried about how nginx in front of nginx would impact performance (some reading tells me it’s negligible), so am keen to see this on live traffic.


😰 Troubleshooting iconv

I only ran into one (!) problem throughout this entire process, which was the following error on PHP8.1 Alpine when using iconv()

iconv(): Wrong charset, conversion from UTF-8 to ASCII//TRANSLIT//IGNORE is not allowed

This is apparently caused by iconv being improperly installed and is something that’s introduced with certain versions of alpine (it’s supposed to be fixed on the latest version, but this was still occurring on 3.17 for me).

I went through a few solutions, but fixed it in the end by installing the package from alpine 3.13. This can be done by adding the following to the Dockerfile —

RUN apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.13/community/gnu-libiconv=1.15-r3
ENV LD_PRELOAD /usr/lib/preloadable_libiconv.so php

🐳 Final Dockerfile

Surprisingly, everything else went smoothly with help from Google, GitHub and StackOverflow for Docker best practices.

I wanted to minimize opportunities to accidentally install the wrong package versions on different images, so wanted to use a single Dockerfile with as much shared code as possible.

I found the following StackOverflow answer super helpful with structuring a Dockerfile (up to date as of 2022) — https://stackoverflow.com/questions/43654656/dockerfile-if-else-condition-with-external-arguments/65624157#65624157

Docker natively supports (1) build targets, which allow you to use the same Dockerfile for multiple images easily, and (2) conditional build stages, wherein a build stage will only be triggered if it’s included in the final build.

What this means is:

  • I can use the same Dockerfile for sail, web, jobs, and scheduler by designating them as different build targets
  • I can include an npm build stage, but only use it for the web build; the npm build stage will be ignored for building any other image
  • Rinse and repeat for any build stages that only conditionally exist for specific build targets

The final Dockerfile looks like this:

########################################################################
# BUILD STAGE: BASE
########################################################################
FROM alpine:${ALPINE_VERSION} AS base
# PHP setup and configuration goes here

########################################################################
# BUILD STAGE: NGINX
########################################################################
FROM base AS base-nginx
# install and configure nginx

########################################################################
# BUILD STAGE: COMPOSER
########################################################################
FROM base AS composer
# install composer and composer packages

########################################################################
# BUILD STAGE: NPM
########################################################################
FROM node:18.14-alpine AS npm
# install npm, npm packages, and build assets
# http://www.tiernok.com/posts/2019/faster-npm-installs-during-ci/
# https://medium.com/trendyol-tech/how-we-reduce-node-docker-image-size-in-3-steps-ff2762b51d5a

########################################################################
# FINAL IMAGE: SAIL
########################################################################
FROM base-nginx AS image-sail

# sail mounts a volume, so no need to copy files over

# configure supervisor
# configure sail user and other sail-specific configurations

EXPOSE 8000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
HEALTHCHECK --timeout=10s CMD curl --silent --fail http://127.0.0.1:8000/fpm-ping

########################################################################
# FINAL IMAGE: WEB
########################################################################
FROM base-nginx AS image-web

COPY --from=npm ... 
COPY --from=composer ...

# configure supervisor
# configure monitoring tools

EXPOSE 8000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
HEALTHCHECK --timeout=10s CMD curl --silent --fail http://127.0.0.1:8000/fpm-ping

########################################################################
# FINAL IMAGE: SCHEDULER
########################################################################
FROM base AS image-scheduler

COPY --from=composer ...

# configure supervisor
# configure monitoring tools
# install supercronic and other scheduler-specific packages

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

########################################################################
# FINAL IMAGE: JOBS
########################################################################
FROM base AS image-jobs

COPY --from=composer ...

# configure supervisor
# configure monitoring tools

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD php artisan horizon:status || exit 1

Looking forward to learning more from rolling this out in production! ☺️