Laravel with PHP 8.2 in an Alpine Container
Tags: laravel, php, docker, alpine
Published: 2020-11-22 02:55:00

When deploying a Laravel application, the goal is to make sure that the deployment process is as fast and secure as possible. A big part of achieving this goal is choosing the right base Linux image to compose the container image where the application will be running and later deployed.

Alpine Linux has shown that there is no faster distro when working with a container for any language. Since Docker's first release, the popularity of the Alpine distro has grown and keeps growing because it is a tiny, container-focused, and security-focused distro.

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

### Content
- [Dockerfile](#dockerfile)
- [Defining image bases](#defining-image-bases)
- [Software installation](#software-installation)
    - [Install essentials](#install-essentials)
    - [Install bash](#install-bash)
    - [Install PHP](#install-php)
    - [Install Composer](#install-composer)
- [Software configuration](#software-configuration)
    - [Configure supervisor](#configure-supervisor)
    - [Configure PHP](#configure-php)
    - [Configure NGINX](#configure-nginx)
- [Build process](#build-process)
- [Container execution](#container-execution)

### 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.

```docker
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 php82 \
    php82-common \
    php82-fpm \
    php82-pdo \
    php82-opcache \
    php82-zip \
    php82-phar \
    php82-iconv \
    php82-cli \
    php82-curl \
    php82-openssl \
    php82-mbstring \
    php82-tokenizer \
    php82-fileinfo \
    php82-json \
    php82-xml \
    php82-xmlwriter \
    php82-simplexml \
    php82-dom \
    php82-pdo_mysql \
    php82-pdo_sqlite \
    php82-tokenizer \
    php82-pecl-redis

RUN ln -s /usr/bin/php82 /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.2-fpm.pid

COPY .docker/php-fpm.conf /etc/php82/php-fpm.conf
COPY .docker/php.ini-production /etc/php82/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.

```docker
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 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, when the instruction `WORKDIR /var/www/html/`  is interpreted, every command execution in the Dockerfile will take place in **/var/www/html/**.

### Software installation

Now that the container image base has been 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 softwares to install, but that's not all. As these pieces of software have dependencies, they also have to be installed. Here is the installation process broken down into understandable pieces:

#### Install essentials

```docker
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 the **/etc/timezone** file. As a result of the command's execution, UTC becomes the standard timezone.

In the second RUN instruction, an apk command appears; apk is the 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 softwares in the base image.

#### Install bash

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

The first RUN instruction says that bash has to be installed. The second instruction sets it as a standard shell by replacing the string **/bin/ash** by **/bin/bash** in the **/etc/passwd** file. This change is because the 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

```docker
RUN apk add --no-cache php82 \
    php82-common \
    php82-fpm \
    php82-pdo \
    php82-opcache \
    php82-zip \
    php82-phar \
    php82-iconv \
    php82-cli \
    php82-curl \
    php82-openssl \
    php82-mbstring \
    php82-tokenizer \
    php82-fileinfo \
    php82-json \
    php82-xml \
    php82-xmlwriter \
    php82-simplexml \
    php82-dom \
    php82-pdo_mysql \
    php82-pdo_sqlite \
    php82-tokenizer \
    php82-pecl-redis

RUN ln -s /usr/bin/php82 /usr/bin/php
```

The first RUN instruction says that PHP and all listed extensions have to be 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.

While the second RUN instruction creates a symbolic link named `php` that points to the file `php82` in the `/usr/bin` directory. 

Lastly, you can find out what the PHP extensions do by checking the [PHP extensions documentation](https://www.php.net/manual/en/extensions.alphabetical.php) and the PHP extension community library [PECL](https://pecl.php.net/package-search.php) pages and searching for them.

#### Install Composer

```docker
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, the 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 the **/usr/local/bin** directory. Lastly, the binary gets removed after composer installation since it has no use for the system any longer.

### Software configuration

Now that all the needed software is installed, it has to be configured and tightened together to make the serving of a Laravel application work as expected.

#### Configure supervisor

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

In this RUN instruction, the Dockerfile specifies 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 the **/etc/supervisor.d/** container folder. As mentioned above, this file contains the instructions that Supervisor will run upon, and these instructions are:

```ini
[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-fpm82
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, which 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 the 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 be 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 the 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 be deactivated by setting maxbytes to 0.

#### Configure PHP

```docker
RUN mkdir -p /run/php/
RUN touch /run/php/php8.2-fpm.pid

COPY .docker/php-fpm.conf /etc/php82/php-fpm.conf
COPY .docker/php.ini-production /etc/php82/php.ini
```

In the first RUN statement, the Dockerfile specifies that the directory _php_ has to be created inside the **/run/** directory. This directory will hold _.pid_ files that contain the process ID specific to the software.

The second statement creates the file php8.2-fpm.pid inside the **/run/php/** directory. Now the Alpine distro has a file to store the process ID that will be created when PHP-FPM starts.

The third statement copies a php.ini-production file from a local .docker folder into the **/etc/php82/** container folder. This file contains all the configurations that PHP will run on. The content of this file was copied from [PHP's official repository on GitHub](https://github.com/php/php-src/blob/master/php.ini-production).

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

```conf
;;;;;;;;;;;;;;;;;;;;
; 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/php82/php-fpm.d/*.conf
```

Notice that php-fpm.conf doesn't have any custom configuration or optimization; feel free to configure this file according to your needs.

#### Configure NGINX
```docker
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 the **/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:

```nginx
# /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 nginx-laravel.conf from a local .docker folder into the **/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:

```nginx
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 be created inside the **/run/** directory. As mentioned in the PHP-FPM configuration session, the run directory holds .pid files where the process ID for a specific software gets written.

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

The sixth statement instructs that a symbolic link to the Alpine standard output has to be 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 to 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.

```docker
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 is installed and properly configured, we need to know how this container image will start serving the application once the container starts and what TCP port to use.

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

The EXPOSE instruction informs the container to listen 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!