# Local configuration

For the local development environment we will be using docker-compose to orchestrate all the containers.

For the purposes of this demo, I will be using a fresh Laravel application that I've just installed at ~/Sites/demo using:

cd ~/Sites
git clone https://github.com/laravel/laravel.git demo

# Docker configuration

All the files used in this guide can be found on GitHub at https://github.com/daursu/laradocker

We will be configuring 4 containers (nginx, php-fpm, redis and mysql). The reason for splitting nginx and php-fpm in separate containers is that each one can scale independently of each other. In a real application you might have 2 instances of php-fpm and only once instance of nginx running. This separation allows us to upgrade nginx and php versions by simply changing the base images.

Let's create a new folder called docker at the root of our project ~/Sites/demo/docker.

# Nginx configuration

Create a new file called nginx.conf in ~/Sites/demo/docker/nginx.conf with the following contents:

pid /var/run/nginx.pid;
worker_processes auto;

events {
  worker_connections 1024;
}

http {
  include mime.types;
  include fastcgi.conf;
  default_type application/octet-stream;
  sendfile on;
  tcp_nopush on;
  server_tokens off;
  client_max_body_size 10M;
  gzip on;
  gzip_disable "msie6";
  gzip_vary on;
  gzip_proxied any;
  gzip_comp_level 6;
  gzip_buffers 16 8k;
  gzip_http_version 1.1;
  gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;

  server {
    listen [::]:80;
    listen 80 default_server;
    server_name _;
    root /app/public;
    index  index.php index.html index.htm;
    access_log /dev/stdout;
    error_log /dev/stdout info;
    disable_symlinks off;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-XSS-Protection "1; mode=block";
    add_header X-Content-Type-Options "nosniff";

    index index.html index.htm 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_split_path_info ^(.+\.php)(/.+)$;
        if (!-f $document_root$fastcgi_script_name) {
          return 404;
        }
        fastcgi_pass php:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
  }
}

INFO

Feel free to customise this file to suit your application needs. The important part is fastcgi_pass php:9000; which routes the requests to the FPM process running in the php container.

# Nginx Dockerfile

We need to define a nginx Dockerfile that will extend from a base nginx image. The purpose of this is to add our application files to the final docker image that can be deployed on production. Let's create a file called nginx.Dockerfile inside ~/Sites/demo/docker/nginx.Dockerfile folder with the following content:

FROM nginx:1.19-alpine

WORKDIR /app/public

COPY ./docker/nginx.conf /etc/nginx/nginx.conf
COPY ./public/* /app/public/

NOTICE

While developing locally, we will mount our local public folder inside the container, however in production the public folder will be built into the Docker image.

# PHP-fpm configuration

Now let's create a configuration file for the php-fpm pool. I will call it www.conf and I will place it in ~/Sites/demo/docker/www.conf with the following contents:

; Start a new pool named 'www'.
; the variable $pool can be used in any directive and will be replaced by the
; pool name ('www' here)
[www]

; Unix user/group of processes
; Note: The user is mandatory. If the group is not set, the default user's group
;       will be used.
user = www-data
group = www-data

; The address on which to accept FastCGI requests.
; Valid syntaxes are:
;   'ip.add.re.ss:port'    - to listen on a TCP socket to a specific IPv4 address on
;                            a specific port;
;   '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on
;                            a specific port;
;   'port'                 - to listen on a TCP socket to all addresses
;                            (IPv6 and IPv4-mapped) on a specific port;
;   '/path/to/unix/socket' - to listen on a unix socket.
; Note: This value is mandatory.
listen = 9000

; Choose how the process manager will control the number of child processes.
; Possible Values:
;   static  - a fixed number (pm.max_children) of child processes;
;   dynamic - the number of child processes are set dynamically based on the
;             following directives. With this process management, there will be
;             always at least 1 children.
;             pm.max_children      - the maximum number of children that can
;                                    be alive at the same time.
;             pm.start_servers     - the number of children created on startup.
;             pm.min_spare_servers - the minimum number of children in 'idle'
;                                    state (waiting to process). If the number
;                                    of 'idle' processes is less than this
;                                    number then some children will be created.
;             pm.max_spare_servers - the maximum number of children in 'idle'
;                                    state (waiting to process). If the number
;                                    of 'idle' processes is greater than this
;                                    number then some children will be killed.
;  ondemand - no children are created at startup. Children will be forked when
;             new requests will connect. The following parameter are used:
;             pm.max_children           - the maximum number of children that
;                                         can be alive at the same time.
;             pm.process_idle_timeout   - The number of seconds after which
;                                         an idle process will be killed.
; Note: This value is mandatory.
pm = dynamic

; The number of child processes to be created when pm is set to 'static' and the
; maximum number of child processes when pm is set to 'dynamic' or 'ondemand'.
; This value sets the limit on the number of simultaneous requests that will be
; served. Equivalent to the ApacheMaxClients directive with mpm_prefork.
; Equivalent to the PHP_FCGI_CHILDREN environment variable in the original PHP
; CGI. The below defaults are based on a server without much resources. Don't
; forget to tweak pm.* to fit your needs.
; Note: Used when pm is set to 'static', 'dynamic' or 'ondemand'
; Note: This value is mandatory.
pm.max_children = 5

; The number of child processes created on startup.
; Note: Used only when pm is set to 'dynamic'
; Default Value: min_spare_servers + (max_spare_servers - min_spare_servers) / 2
pm.start_servers = 2

; The desired minimum number of idle server processes.
; Note: Used only when pm is set to 'dynamic'
; Note: Mandatory when pm is set to 'dynamic'
pm.min_spare_servers = 1

; The desired maximum number of idle server processes.
; Note: Used only when pm is set to 'dynamic'
; Note: Mandatory when pm is set to 'dynamic'
pm.max_spare_servers = 3

; The number of seconds after which an idle process will be killed.
; Note: Used only when pm is set to 'ondemand'
; Default Value: 10s
;pm.process_idle_timeout = 10s;

; The number of requests each child process should execute before respawning.
; This can be useful to work around memory leaks in 3rd party libraries. For
; endless request processing specify '0'. Equivalent to PHP_FCGI_MAX_REQUESTS.
; Default Value: 0
;pm.max_requests = 500

# PHP Dockerfile

In order to enable custom PHP extension we will have to extend from the base php docker image. Also in this step we'll use a staggered build to install the required composer packages.

Let's create a file called php.Dockerfile inside ~/Sites/demo/docker/php.Dockerfile folder with the following content:

# ----------------------
# The FPM base container
# ----------------------
FROM php:7.4-fpm as dev

RUN docker-php-ext-install -j$(nproc) pdo_mysql

WORKDIR /app

# ----------------------
# Composer install step
# ----------------------
FROM composer:1.10 as build

WORKDIR /app

COPY composer.* ./
COPY database/ database/

RUN composer install \
    --ignore-platform-reqs \
    --no-interaction \
    --no-plugins \
    --no-scripts \
    --prefer-dist

# ----------------------
# npm install step
# ----------------------
FROM node:12-alpine as node

WORKDIR /app

COPY *.json *.mix.js /app/
COPY resources /app/resources

RUN mkdir -p /app/public \
    && npm install && npm run production

# ----------------------
# The FPM production container
# ----------------------
FROM dev

COPY ./docker/www.conf /usr/local/etc/php-fpm.d/www.conf
COPY . /app
COPY --from=build /app/vendor/ /app/vendor/
COPY --from=node /app/public/js/ /app/public/js/
COPY --from=node /app/public/css/ /app/public/css/
COPY --from=node /app/mix-manifest.json /app/public/mix-manifest.json

RUN chmod -R 777 /app/storage

Add any extensions your application needs to docker-php-ext-install. In this example I've installed the PDO mysql driver.

More details about how to install custom extensions can be found here: https://hub.docker.com/_/php/

# docker-compose.yml

The docker-compose.yml contains the structure of our docker environment. It defines the services we are running, their environment variables and how they interact with each other.

In order to support multiple environments for our project, we will split our docker-compose.yml file into two parts, one called docker-compose.yml, which mimics the production environment, and another one called docker-compose.override.yml that provides local development override.

TIP

By default, the configuration in docker-compose.override.yml takes precedence.

# Base docker-compose.yml

Inside my ~/Sites/demo folder I will create a new file called docker-compose.yml with the following content:

version: "3.8"
services:
    nginx:
        build:
            context: .
            dockerfile: ./docker/nginx.Dockerfile
        restart: always
        depends_on:
            - php
        ports:
            - "8080:80"
        networks:
            - default

    php:
        build:
            context: .
            dockerfile: ./docker/php.Dockerfile
        working_dir: /app
        env_file: .env
        restart: always
        expose:
            - "9000"

# Local dev overrides

Next, let's create a new file called docker-compose.override.yml. Paste the following contents in:

version: "3.8"
services:
  redis:
    image: redis:6.0-alpine
    expose:
      - "6379"

  db:
    image: mysql:8
    ports:
      - "3307:3306"
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: laravel
    volumes:
      - db-data:/var/lib/mysql

  nginx:
    image: nginx:1.19-alpine
    environment:
      VIRTUAL_HOST: testing.local
    restart: "no"
    volumes:
      - ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./public:/app/public:ro

  php:
    build:
      target: dev
    restart: "no"
    depends_on:
      - composer
      - redis
      - db
    volumes:
      - ./:/app
      - ./docker/www.conf:/usr/local/etc/php-fpm.d/www.conf:ro

  node:
    image: node:12-alpine
    working_dir: /app
    volumes:
      - ./:/app
    command: sh -c "npm install && npm run watch"

  composer:
    image: composer:1.10
    working_dir: /app
    environment:
      SSH_AUTH_SOCK: /ssh-auth.sock
    volumes:
      - ./:/app
      - "$SSH_AUTH_SOCK:/ssh-auth.sock"
      - /etc/passwd:/etc/passwd:ro
      - /etc/group:/etc/group:ro
    command: composer install --ignore-platform-reqs --no-scripts

volumes:
  db-data:

The local environment will be mounting our local code as a volume so that changes are reflected immediately inside the container without the need to rebuild the image.

Locally we are starting up MySQL and redis containers. You will notice that these two containers are not part of the production docker-compose.yml file, as stateful containers bring in a lot more complexity in terms of scale and deployment. More information on this can be found in the Best Practices section.

We also added two new services: composer & node. The composer container will install all the dependencies listed in composer.json at start-up. The node container will install all the npm packages and will run npm run watch in the background, which is part of Laravel Mix.

In the next chapter, we will look at how to manually invoke the composer and node containers.

# Application .env

Next step is to update the .env file with following:

DB_HOST=db
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=secret

CACHE_DRIVER=redis
SESSION_DRIVER=redis

WARNING

In order to use redis you will need to install predis\predis composer package.

# Logging

When running applications in Docker containers, it's a good practice to write the application logs to stdout/stderr instead of writing them locally to disk. This is because docker has native plugins/drivers to handle the logs and store or forward them to a third party service like CloudWatch. You can read more about the logging features in Docker on the official documentation page.

To get the Laravel application to write to stdout, we need to update the config/logging.php file. Add the following block:

        'stdout' => [
            'driver' => 'monolog',
            'handler' => StreamHandler::class,
            'formatter' => env('LOG_STDOUT_FORMATTER'),
            'with' => [
                'stream' => 'php://stdout',
            ],
        ],

Change the default logging driver to stdout. In .env add the following line:

LOG_CHANNEL=stdout

If you are using a stack driver, you can replace your file log driver with stdout. For example if you use the single log driver in the stack, replace it with stdout:

        'stack' => [
            'driver' => 'stack',
            'channels' => ['stdout'],
            'ignore_exceptions' => false,
        ],

# Customizing versions

By default, I have required the latest versions of each service in docker-compose.override.yml

  • PHP 7.4
  • Nginx 1.19
  • MySQL 8
  • Redis 6

You can easily swap the version of a service with another one. For example, if you would like to use MySQL 5.7 for example simply change image: mysql:8 to image: mysql:5.7.