How to use Alpine Container with PHP8.0 in Laravel

To be able to run an application just PHP and Composer isn’t enough NGINX and Supervisor it’s also required, and here is where a little of complexity comes in. But don’t worry, a Dockerfile it’s going to be dissected, and you will get to understand why things are the way they are.

Content

Dockerfile

Down below, there is an entire Dockerfile used locally and in production to serve a Laravel application. Notice that it’s not optimized to have a minimal number of layers, and that is on purpose, since we will grab small pieces of the file and understand what each part does.

FROM alpine:latest
WORKDIR /var/www/html/
# Essentials
RUN echo "UTC" > /etc/timezone
RUN apk add --no-cache zip unzip curl sqlite nginx supervisor
# Installing bash
RUN apk add bash
RUN sed -i 's/bin\/ash/bin\/bash/g' /etc/passwd
# Installing PHP
RUN apk add --no-cache php8 \
    php8-common \
    php8-fpm \
    php8-pdo \
    php8-opcache \
    php8-zip \
    php8-phar \
    php8-iconv \
    php8-cli \
    php8-curl \
    php8-openssl \
    php8-mbstring \
    php8-tokenizer \
    php8-fileinfo \
    php8-json \
    php8-xml \
    php8-xmlwriter \
    php8-simplexml \
    php8-dom \
    php8-pdo_mysql \
    php8-pdo_sqlite \
    php8-tokenizer \
    php8-pecl-redis
RUN ln -s /usr/bin/php8 /usr/bin/php
# Installing composer
RUN curl -sS https://getcomposer.org/installer -o composer-setup.php
RUN php composer-setup.php --install-dir=/usr/local/bin --filename=composer
RUN rm -rf composer-setup.php
# Configure supervisor
RUN mkdir -p /etc/supervisor.d/
COPY .docker/supervisord.ini /etc/supervisor.d/supervisord.ini
# Configure PHP
RUN mkdir -p /run/php/
RUN touch /run/php/php8.0-fpm.pid
COPY .docker/php-fpm.conf /etc/php8/php-fpm.conf
COPY .docker/php.ini-production /etc/php8/php.ini
# Configure nginx
COPY .docker/nginx.conf /etc/nginx/
COPY .docker/nginx-laravel.conf /etc/nginx/modules/
RUN mkdir -p /run/nginx/
RUN touch /run/nginx/nginx.pid
RUN ln -sf /dev/stdout /var/log/nginx/access.log
RUN ln -sf /dev/stderr /var/log/nginx/error.log
# Building process
COPY . .
RUN composer install --no-dev
RUN chown -R nobody:nobody /var/www/html/storage
EXPOSE 80
CMD ["supervisord", "-c", "/etc/supervisor.d/supervisord.ini"]

Defining image bases

The first step towards the construction of a Dockerfile is to create the file itself and define a Linux distribution and its version. Once that is done, you can start composing your Dockerfile with the instructions needed to build your container image.

FROM alpine:latest
WORKDIR /var/www/html/

The FROM instruction sets the Base Image for subsequent instructions. Notice that alpine:latest gets defined, which sets alpine as the base Linux image. After the distro name, there is a : used to specify a tag or version, so when the instruction FROM alpine:latest gets interpreted, it will set alpine at the latest version as the base image.

While the WORKDIR instruction sets the working directory for any RUN, CMD, ENTRYPOINT, COPY and ADD instructions that follow it in the Dockerfile. So, when the instruction WORKDIR /var/www/html/ is interpreted, every command execution in the Dockerfile will take place into /var/www/html/.

Software installation

Now that the container image base got defined, it’s time to start looking into the software that we need to install to run the application. As mentioned, PHP, Composer, NGINX, and Supervisor are software programs to install, but that’s not all. As this software has dependencies, they also have to be installed. Here is the installation process broken into explained pieces, so you can understand it.

Install essentials

RUN echo "UTC" > /etc/timezone
RUN apk add --no-cache zip unzip curl sqlite nginx supervisor

The first RUN instruction will execute any commands in a new layer on top of the current image and commit the results. Hence, when RUN echo "UTC" > /etc/timezone is interpreted, the echo command will print out the UTC string into /etc/timezone file. As a result of the command execution, UTC becomes the standard timezone.

In the second RUN instruction, an apk command appears, apk is Alpine package manager, another well-known package manager is apt from Ubuntu. With that said, when RUN apk add --no-cache zip unzip curl sqlite nginx supervisor is processed, it installs those software programs in the base image.

Install bash

RUN apk add bash
RUN sed -i 's/bin\/ash/bin\/bash/g' /etc/passwd

The first RUN instruction tells that bash has to be installed. The second instruction sets it as a standard shell by replacing the string /bin/ash by /bin/bash into the /etc/passwd file. This change is because Alpine standard shell, ash, works differently, and these differences can get in your way when you or your team need to execute a shell script in the container.

Install PHP

RUN apk add --no-cache php8 \
    php8-common \
    php8-fpm \
    php8-pdo \
    php8-opcache \
    php8-zip \
    php8-phar \
    php8-iconv \
    php8-cli \
    php8-curl \
    php8-openssl \
    php8-mbstring \
    php8-tokenizer \
    php8-fileinfo \
    php8-json \
    php8-xml \
    php8-xmlwriter \
    php8-simplexml \
    php8-dom \
    php8-pdo_mysql \
    php8-pdo_sqlite \
    php8-tokenizer \
    php8-pecl-redis
RUN ln -s /usr/bin/php8 /usr/bin/php

The first RUN instruction, tells that PHP and all listed extensions have to get installed. As mentioned before, this Dockerfile gets used to serve Laravel applications, so the PHP extensions are arbitrary and may change depending on the framework or application you are trying to run.

The second instruction creates a symbolic link from /usr/bin/php8 into /usr/bin/php. Although the path is the same, we change the binary name because it’s more common to use just php as a command than php8.

Lastly, you can find what the PHP extensions does by checking PHP extensions documentation and the PHP extension community library PECL pages and search for them.

Install Composer

RUN curl -sS https://getcomposer.org/installer -o composer-setup.php
RUN php composer-setup.php --install-dir=/usr/local/bin --filename=composer
RUN rm -rf composer-setup.php

In this RUN instruction composer binary, composer-setup.php gets downloaded from the composer’s official page. Then in the second instruction, the binary gets used to install composer into /usr/local/bin directory. Lastly, the binary gets removed after composer installation since his binary has no use to the system any longer.

Software configuration

Now that all needed software it’s installed, they have to be configured and tight together to make the serving of a Laravel application work as expected.

Configure supervisor

RUN mkdir -p /etc/supervisor.d/
COPY .docker/supervisord.ini /etc/supervisor.d/supervisord.ini

In this RUN instruction, the Dockerfile is specifying that the directory supervisor.d has to be created inside the /etc/ directory. This directory will hold initializer files that specify sets of instructions that the Supervisor will run upon when the OS starts, in this case when the container starts, since these two events cannot happen without each other.

In the second RUN instruction, the supervisord.ini file gets copied from a local .docker folder into /etc/supervisor.d/ container folder. As mentioned above, this file contains the instructions that Supervisor will run upon, and these instructions are:

[supervisord]
nodaemon=true
[program:nginx]
command=nginx
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:php-fpm]
command=php-fpm8
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

Explaining supervisor.ini

  • nodaemon=true

Start Supervisor in the foreground instead of daemonizing.

  • command=nginx

The command that will run when Supervisor starts.

  • stdout_logfile=/dev/stdout

Redirect all output to the Alpine standard output device that is the container itself, allowing us to see Supervisor logs about NGINX execution when running docker logs MY_CONTAINER or docker-compose up to start container stack.

  • stdout_logfile_maxbytes=0

The maximum number of bytes that can get consumed by stdout_logfile before it rotates, since files didn’t get written, has to get deactivated by setting maxbytes to 0.

  • stderr_logfile=/dev/stderr

Redirect all errors to the Alpine standard error device that is the container itself, allowing us to see Supervisor logs about NGINX execution when running docker logs MY_CONTAINER or docker-compose up to start container stack.

  • stderr_logfile_maxbytes=0

The maximum number of bytes that can get consumed by stderr_logfile before it rotates, since files didn’t get written, has to get deactivated by setting maxbytes to 0.

Configure PHP

RUN mkdir -p /run/php/
RUN touch /run/php/php8.0-fpm.pid
COPY .docker/php.ini-production /etc/php8/php.ini
COPY .docker/php-fpm.conf /etc/php8/php-fpm.conf

In the first RUN statement, the Dockerfile is specifying that the directory php has to get created inside the /run/ directory. This directory will hold .pid files that contain the process ID specific to the software.

The second statement, create the file php8.0-fpm.pid inside /run/php/ directory. Now the Alpine distro has a file to store the process ID that will get created when PHP-FPM starts.

The third statement copies a php.ini-production file from a local .docker folder into /etc/php8/ container folder. This file contains all the configurations that PHP will run upon, the content of this file got copied from PHP’s official repository on GitHub.

The fourth statement copies a php-fpm.conf file from a local .docker folder into /etc/php8/ container folder. This file contains all the configurations that PHP-FPM will run upon, and here are the configurations:

;;;;;;;;;;;;;;;;;;;;
; FPM Configuration ;
;;;;;;;;;;;;;;;;;;;;;
; All relative paths in this configuration file are relative to PHP's install
; prefix (/usr). This prefix can be dynamically changed by using the
; '-p' argument from the command line.
;;;;;;;;;;;;;;;;;;
; Global Options ;
;;;;;;;;;;;;;;;;;;
[global]
; Pid file
; Note: the default prefix is /var
; Default Value: none
pid = /run/php/php8.0-fpm.pid
; Error log file
; If it's set to "syslog", log is sent to syslogd instead of being written
; in a local file.
; Note: the default prefix is /var
; Default Value: log/php-fpm.log
error_log = /proc/self/fd/2
; syslog_facility is used to specify what type of program is logging the
; message. This lets syslogd specify that messages from different facilities
; will be handled differently.
; See syslog(3) for possible values (ex daemon equiv LOG_DAEMON)
; Default Value: daemon
;syslog.facility = daemon
; syslog_ident is prepended to every message. If you have multiple FPM
; instances running on the same server, you can change the default value
; which must suit common needs.
; Default Value: php-fpm
;syslog.ident = php-fpm
; Log level
; Possible Values: alert, error, warning, notice, debug
; Default Value: notice
;log_level = notice
; If this number of child processes exit with SIGSEGV or SIGBUS within the time
; interval set by emergency_restart_interval then FPM will restart. A value
; of '0' means 'Off'.
; Default Value: 0
;emergency_restart_threshold = 0
; Interval of time used by emergency_restart_interval to determine when
; a graceful restart will be initiated.  This can be useful to work around
; accidental corruptions in an accelerator's shared memory.
; Available Units: s(econds), m(inutes), h(ours), or d(ays)
; Default Unit: seconds
; Default Value: 0
;emergency_restart_interval = 0
; Time limit for child processes to wait for a reaction on signals from master.
; Available units: s(econds), m(inutes), h(ours), or d(ays)
; Default Unit: seconds
; Default Value: 0
;process_control_timeout = 0
; The maximum number of processes FPM will fork. This has been design to control
; the global number of processes when using dynamic PM within a lot of pools.
; Use it with caution.
; Note: A value of 0 indicates no limit
; Default Value: 0
; process.max = 128
; Specify the nice(2) priority to apply to the master process (only if set)
; The value can vary from -19 (highest priority) to 20 (lower priority)
; Note: - It will only work if the FPM master process is launched as root
;       - The pool process will inherit the master process priority
;         unless it specified otherwise
; Default Value: no set
; process.priority = -19
; Send FPM to background. Set to 'no' to keep FPM in foreground for debugging.
; Default Value: yes
daemonize = no
; Set open file descriptor rlimit for the master process.
; Default Value: system defined value
;rlimit_files = 1024
; Set max core size rlimit for the master process.
; Possible Values: 'unlimited' or an integer greater or equal to 0
; Default Value: system defined value
;rlimit_core = 0
; Specify the event mechanism FPM will use. The following is available:
; - select     (any POSIX os)
; - poll       (any POSIX os)
; - epoll      (linux >= 2.5.44)
; - kqueue     (FreeBSD >= 4.1, OpenBSD >= 2.9, NetBSD >= 2.0)
; - /dev/poll  (Solaris >= 7)
; - port       (Solaris >= 10)
; Default Value: not set (auto detection)
;events.mechanism = epoll
; When FPM is build with systemd integration, specify the interval,
; in second, between health report notification to systemd.
; Set to 0 to disable.
; Available Units: s(econds), m(inutes), h(ours)
; Default Unit: seconds
; Default value: 10
;systemd_interval = 10
;;;;;;;;;;;;;;;;;;;;
; Pool Definitions ;
;;;;;;;;;;;;;;;;;;;;
; Multiple pools of child processes may be started with different listening
; ports and different management options.  The name of the pool will be
; used in logs and stats. There is no limitation on the number of pools which
; FPM can handle. Your system will tell you anyway :)
; Include one or more files. If glob(3) exists, it is used to include a bunch of
; files from a glob(3) pattern. This directive can be used everywhere in the
; file.
; Relative path can also be used. They will be prefixed by:
;  - the global prefix if it's been set (-p argument)
;  - /usr otherwise
include=/etc/php8/php-fpm.d/*.conf

Notice that the php-fpm.conf don’t have any custom configuration or optimization, feel free to configure this file according to your needs.

Configure NGINX

COPY .docker/nginx.conf /etc/nginx/
COPY .docker/nginx-laravel.conf /etc/nginx/modules/
RUN mkdir -p /run/nginx/
RUN touch /run/nginx/nginx.pid
RUN ln -sf /dev/stdout /var/log/nginx/access.log
RUN ln -sf /dev/stderr /var/log/nginx/error.log

In this first statement, nginx.conf gets copied from a local .docker folder into /etc/nginx/ container folder. This file contains all the configurations that NGINX will use to run upon it, and down below you can check the file content:

# /etc/nginx/nginx.conf
user nobody;
# NGINX will run in the foreground
daemon off;
# Set number of worker processes automatically based on number of CPU cores.
worker_processes auto;
# Enables the use of JIT for regular expressions to speed-up their processing.
pcre_jit on;
# Configures default error logger.
error_log /var/log/nginx/error.log warn;
# Uncomment to include files with config snippets into the root context.
# NOTE: This will be enabled by default in Alpine 3.15.
# include /etc/nginx/conf.d/*.conf;
events {
    # The maximum number of simultaneous connections that can be opened by
    # a worker process.
    worker_connections 1024;
}
http {
    # Includes mapping of file name extensions to MIME types of responses
    # and defines the default type.
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    # Includes files with directives to load dynamic modules.
    include /etc/nginx/modules/*.conf;
    # Name servers used to resolve names of upstream servers into addresses.
    # It's also needed when using tcpsocket and udpsocket in Lua modules.
    #resolver 1.1.1.1 1.0.0.1 2606:4700:4700::1111 2606:4700:4700::1001;
    # Don't tell nginx version to the clients. Default is 'on'.
    server_tokens off;
    # Specifies the maximum accepted body size of a client request, as
    # indicated by the request header Content-Length. If the stated content
    # length is greater than this size, then the client receives the HTTP
    # error code 413. Set to 0 to disable. Default is '1m'.
    client_max_body_size 1m;
    # Sendfile copies data between one FD and other from within the kernel,
    # which is more efficient than read() + write(). Default is off.
    sendfile on;
    # Causes nginx to attempt to send its HTTP response head in one packet,
    # instead of using partial frames. Default is 'off'.
    tcp_nopush on;

    # Enables the specified protocols. Default is TLSv1 TLSv1.1 TLSv1.2.
    # TIP: If you're not obligated to support ancient clients, remove TLSv1.1.
    ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
    # Path of the file with Diffie-Hellman parameters for EDH ciphers.
    # TIP: Generate with: `openssl dhparam -out /etc/ssl/nginx/dh2048.pem 2048`
    #ssl_dhparam /etc/ssl/nginx/dh2048.pem;
    # Specifies that our cipher suits should be preferred over client ciphers.
    # Default is 'off'.
    ssl_prefer_server_ciphers on;
    # Enables a shared SSL cache with size that can hold around 8000 sessions.
    # Default is 'none'.
    ssl_session_cache shared:SSL:2m;
    # Specifies a time during which a client may reuse the session parameters.
    # Default is '5m'.
    ssl_session_timeout 1h;
    # Disable TLS session tickets (they are insecure). Default is 'on'.
    ssl_session_tickets off;

    # Enable gzipping of responses.
    #gzip on;
    # Set the Vary HTTP header as defined in the RFC 2616. Default is 'off'.
    gzip_vary on;

    # Helper variable for proxying websockets.
    map $http_upgrade $connection_upgrade {
        default upgrade;
        '' close;
    }

    # Specifies the main log format.
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
            '$status $body_bytes_sent "$http_referer" '
            '"$http_user_agent" "$http_x_forwarded_for"';
    # Sets the path, format, and configuration for a buffered log write.
    access_log /var/log/nginx/access.log main;

    # Includes virtual hosts configs.
    include /etc/nginx/http.d/*.conf;
}
# TIP: Uncomment if you use stream module.
#include /etc/nginx/stream.conf;

The third statement, copies a nginx-laravel.conf from a local .docker folder into /etc/nginx/modules/ container folder. This file contains all the configurations that NGINX will use to serve Laravel correctly, and down below you can check the file content:

server {
    listen 80;
    server_name localhost;
    root /var/www/html/public;
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    index index.php;
    charset utf-8;
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }
    error_page 404 /index.php;
    location ~ \.php$ {
        fastcgi_pass localhost:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
    location ~ /\.(?!well-known).* {
        deny all;
    }
}

The fourth statement specifies that the directory nginx has to get created inside the /run/ directory. As mentioned in the PHP-FPM configuration session, the run directory holds .pid files where the process ID to a specific software gets written.

In the fifth statement, create the file nginx.pid inside /run/nginx/ directory. Now, the Alpine distro has a file to store the process ID that will get created when NGINX starts.

The sixth statement instructs that a symbolic link of the Alpine standard output has to get created at /var/log/nginx/access.log. This configuration, as mentioned in the Supervisor sections, is what allows us to see NGINX logs from containers.

Lastly, the seventh statement instructs that a symbolic link of the Alpine standard error gets created at /var/log/nginx/error.log. This configuration, as mentioned in the Supervisor sections, is what allows us to see NGINX errors from containers.

Build process

The build process is where the application gets copied into the container, and its dependencies get installed, leaving the Laravel application ready to be served by NGINX, PHP-FPM, and Supervisor.

COPY . .
RUN composer install --no-dev

At the COPY statement, all Laravel files and folders from the directory where the Dockerfile is, are copied into the working directory specified at the WORKDIR instruction.

At the RUN statement, production dependencies from the Laravel application get installed, making the application ready to be served by Supervisor, NGINX, and PHP-FPM.

Container execution

Now that everything got installed and properly configured, we need to tell how this container image will start serving the application once the container starts and what TCP port to use.

EXPOSE 80
CMD ["supervisord", "-c", "/etc/supervisor.d/supervisord.ini"]

The EXPOSE instruction informs that the container listens on the specified network ports at runtime, while the purpose of the CMD instruction is to provide a default command for an executing Docker container.


Now your Dockerfile is finally done, and you can build a container from it by executing docker build -t laravel-alpine:latest . --no-cache in your terminal.

Happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *

en_USEnglish