There are many ways you can add Docker to your Laravel application, but how do you add docker to your Laravel application when you need sub domain support? In this article I will walk through how I have done it in the past, this isn’t the only way to achieve this - but it is a way that I found works well for me.
It all starts with a docker-compose file, like most docker builds - I prefer to use docker-compose as it allows me to be pretty specific on behaviour and environment variables etc. Create a docker-compose.yaml
in the root of your Laravel application. We are going to want to add 5 services to this: nginx, app, redis, mysql and traefik.
You do not need to keep redis if you do not wish to use it, however it is something I include in every laravel build as it provides great support for both Cache and Queue workers. Let’s walk through the compose file now:
Our Nginx Service
nginx:
container_name: "${PROJECT_NAME}_nginx"
build:
context: ./docker/nginx
dockerfile: Dockerfile
depends_on:
- app
volumes:
- ./:/var/www/vhost/crm:cached
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
- ./docker/nginx/conf.d:/etc/nginx/conf.d
- ./docker/nginx/ssl:/etc/nginx/ssl
working_dir: /var/www/vhost/
ports:
- '443:443'
- '9008:9008'
networks:
- crm
labels:
- 'traefik.http.routers.${PROJECT_NAME}_nginx.rule=HostRegexp(`${APP_DOMAIN}`, `{subdomain:[a-z]+.${APP_DOMAIN}}`)'
- 'traefik.http.routers.${PROJECT_NAME}_nginx.priority=1'
- 'traefik.docker.network=proxy'
In my .env
file I add a new variable called PROJECT_NAME
which could be different to my app name, something that is more system friendly and identifiable. Your docker-compose file can pick up this environment variable, and use it as part of it’s build process. We want the name of our nginx container to be specific for our project, so we use the env variable and add _nginx
on the end. We then want to specify the build portion of our service, we want to pull in a specific Dockerfile
for nginx to allow us to configure the container in our own way. This container will depend on the app
service being available, otherwise we will not be able to serve anything useful. We also want to mount a few volumes:
- Our application itself, which we will cache between builds
- Our nginx configuration
- Our SSL certificates.
We then set our working directory, ports, and which network we want to run on. Finally we get to the labels part of our service, we want nginx to handle the routing - but we will have a layer around that which handles the trafic ingress and sends it to the container it is needed at. The important label is the first one 'traefik.http.routers.${PROJECT_NAME}_nginx.rule=HostRegexp(
${APP_DOMAIN},
{subdomain:[a-z]+.${APP_DOMAIN}})'
what this does is tell our trefik service that we want a router for this project and the nginx rule should match a specific regular expression. As you can see we have another .env
variable called APP_DOMAIN
which is what we want our container to respond as, I usually set this as project-name.localhost
- it is important to use .localhost
so that traefik will work correctly here. But all the regex does is match any subdomain with the app domain part and route it to nginx to pass to your Laravel application. Our docker network is set to proxy, so that requests proxy through traefik to our nginx service, and we want the priority to be 1.
Let’s also have a look at the other parts we need aside from the docker-compose definition.
The nginx Dockerfile should be created under: docker/nginx/Dockerfile
and contain the following:
# Offical Docker Image for Nginx
# https://hub.docker.com/_/nginx
FROM nginx:alpine
# Set Current Directory
WORKDIR /var/www/vhost/
The nginx configuration should be created under: docker/nginx/nginx.conf
and contain the following:
pid /run/nginx.pid;
worker_processes auto;
worker_rlimit_nofile 65535;
events {
multi_accept on;
worker_connections 65535;
}
http {
charset utf-8;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
server_tokens off;
log_not_found off;
types_hash_max_size 2048;
client_max_body_size 16M;
# MIME
include mime.types;
default_type application/octet-stream;
# logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;
# SSL
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# Diffie-Hellman parameter for DHE ciphersuites
#ssl_dhparam /etc/nginx/dhparam.pem;
# OWASP B (Broad Compatibility) configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256;
ssl_prefer_server_ciphers on;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 208.67.222.222 208.67.220.220 valid=60s;
resolver_timeout 2s;
# load configs
include /etc/nginx/conf.d/*.conf;
}
Our sites configuration should be be: docker/nginx/conf.g/site.conf
and contain the following:
server {
#listen 80;
listen [::]:80;
# For https
listen 443 ssl;
listen [::]:443 ssl ipv6only=on;
ssl_certificate /etc/nginx/ssl/app-cert.pem;
ssl_certificate_key /etc/nginx/ssl/app-key.pem;
root /var/www/vhost/crm/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
try_files $uri /index.php =404;
# We are using our app service container name instead of 127.0.0.1 as our connection
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
#fixes timeouts
fastcgi_read_timeout 600;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt/;
log_not_found off;
}
error_log /var/log/nginx/laravel_error.log;
access_log /var/log/nginx/laravel_access.log;
}
Finally we will need to have our SSL configuration created under: docker/nginx/ssl/openssl.cnf
and contain the following:
[req]
default_bits = 2048
default_md = sha256
encrypt_key = no
prompt = no
distinguished_name = subject
req_extensions = req_ext
x509_extensions = x509_ext
[ subject ]
C = Country
ST = State
L = Location
O = Organisation
OU = Team
emailAddress = email@example.com
CN = localhost
[ req_ext ]
subjectKeyIdentifier = hash
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = @alternate_names
nsComment = "OpenSSL Generated Certificate"
[ x509_ext ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = @alternate_names
nsComment = "OpenSSL Generated Certificate"
[ alternate_names ]
DNS.1 = localhost
IP.1 = 127.0.0.1
Finally we want to be able to create SSL certificates for our application, so run the following command inside docker/nginx/ssl
:
mkcert "*.crm.localhost" "crm.localhost"
Make sure you swap crm.localhost
above to whatever your app domain is set to!
We then want to rename _wildcard.crm.localhost+1.pem
to app-cert.pem
, and _wildcard.crm.localhost_1-key.pem
to app-key.pem
so that our application responds over SSL. Again your file names may differ.
Our App Service
Our App Service is a little more typical and has no requirements for it on traefik as nginx handles loading this:
app:
container_name: "${PROJECT_NAME}_php"
build:
context: ./docker/php
dockerfile: Dockerfile
environment:
PHP_MEMORY_LIMIT: '512M'
COMPOSER_MEMORY_LIMIT: '-1'
user: 501:501
volumes:
- ./:/var/www/vhost/crm:cached
working_dir: /var/www/vhost/crm
ports:
- '9003:9003'
networks:
- crm
Again, like our nginx service, we use the Project Name env variable to set the containers name, ensuring it is easy to spot and manage. We then load in a Dockerfile so we can be specific on our build, and set some environment variables. We can control the PHP memory limit here easily, and we set the composer memory limit to -1
- which as of composer 2.* is not as important. This is something I used to use when composer would run out of memory sometimes in fetching dependencies in large projects, I keep this in still more as a safety net than anything else. We set our user to our current user, on a mac if you open your terminal and run id
you will get an output - find the ID that correlates to your user account and replace this in the service.
We have a few additional configuration files to add to our app service, so inside docker/php
we will need to create a Dockerfile
and add the following to it:
# Offical Docker Image for PHP
# https://hub.docker.com/_/php
FROM php:8.1-fpm
# Set Current Directory
WORKDIR /var/www/vhost/
# Install dependencies
RUN apt-get clean && apt-get update && apt-get upgrade -y && apt-get install -y \
git \
libcurl4-openssl-dev \
libonig-dev \
libpng-dev \
libssl-dev \
libicu-dev \
libxml2-dev \
libzip-dev \
unzip \
wget \
zip \
tzdata
RUN docker-php-ext-configure intl
# PHP Extensions
RUN docker-php-ext-install \
bcmath \
exif \
gd \
mysqli \
opcache \
pdo_mysql \
pcntl \
xml \
zip \
intl
# Install Composer from Official Docker Image
# https://hub.docker.com/_/composer
COPY --from=composer:2.2 /usr/bin/composer /usr/bin/composer
This dockerfile took me awhile to figure out when I first created it, as PHP 8.1 comes with some extensions pre-built now so cannot find certain extensions to install as they now belong in core. If you are converting your own Dockerfile for PHP here from pre 8.1 please bear that in mind.
Then we have an opcache
and redis
ini file within docker/php/config
:
[opcache]
opcache.enable=1
; 0 means it will check on every request
; 0 is irrelevant if opcache.validate_timestamps=0 which is desirable in production
opcache.revalidate_freq=0
opcache.validate_timestamps=1
opcache.max_accelerated_files=10000
opcache.memory_consumption=192
opcache.max_wasted_percentage=10
opcache.interned_strings_buffer=16
opcache.fast_shutdown=1
[redis]
You can configure the PHP extensions how works best for you within these now.
The Redis Service
Much like other services, we use the project name env variable to prefix the container name, followed by the name of the service. We then want to set the ports volumes and network:
redis:
image: redis:latest
container_name: "${PROJECT_NAME}_redis"
ports:
- '6379:6379'
volumes:
- 'crm_redis:/data'
networks:
- crm
As you can see, this is a super simple docker service with very little customisation needed.
The MySQL Service
Again, we prefix the MySQL service with the project name env variable, and then we set some environment variables for the container so that we can create the database with credentials etc:
mysql:
image: mariadb:latest
container_name: "${PROJECT_NAME}_mysql"
environment:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
MYSQL_DATABASE: '${DB_DATABASE}'
MYSQL_USER: '${DB_USERNAME}'
MYSQL_PASSWORD: '${DB_PASSWORD}'
MYSQL_ROOT_HOST: '%'
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
restart: always
volumes:
- 'crm_mysql:/data'
ports:
- '${FORWARD_DB_PORT:-4406}:3306'
networks:
- crm
These env variables will be pulled from your .env
file, so make sure you adjust these as required before running any docker commands.
Our Traefik Service
This is one of our most important services for our sub-domain set up, traefik basically acts as an ingress service for all traffic coming in on docker setup, and then proxies the request to one of its “routers” which we defined on our nginx service as a label. It is not an overly complex set up, but can be hard to get right (which is why I am writing this article).
traefik:
image: traefik:v2.0
container_name: "${PROJECT_NAME}_traefik"
restart: always
command:
- --entrypoints.web.address=:80
- --providers.docker=true
- --api.insecure=true
- --log.level=debug
volumes:
- '/var/run/docker.sock:/var/run/docker.sock'
ports:
- '80:80'
- '8080:8080'
networks:
- crm
We have a few options for our command, where we want to specify the web address to see entrypoints to port 80 (we aren’t using port 80 for nginx so this is fine). We then want to set the provider to be docker and the api to be insecure. I am not 100% sure on what all of these commands do, as I do not pretend to be an expert with docker. Finally we want to mount the docker socket we have to the one in the container to allow it to all connect and allow us to smile. This may be different for yourself, so if you have issues make sure you check this volume if you do encounter issues.
The full docker compose setup
version: '3'
services:
nginx:
container_name: "${PROJECT_NAME}_nginx"
build:
context: ./docker/nginx
dockerfile: Dockerfile
depends_on:
- app
volumes:
- ./:/var/www/vhost/crm:cached
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
- ./docker/nginx/conf.d:/etc/nginx/conf.d
- ./docker/nginx/ssl:/etc/nginx/ssl
working_dir: /var/www/vhost/
ports:
- '443:443'
- '9008:9008'
networks:
- crm
labels:
- 'traefik.http.routers.${PROJECT_NAME}_nginx.rule=HostRegexp(`${APP_DOMAIN}`, `{subdomain:[a-z]+.${APP_DOMAIN}}`)'
- 'traefik.http.routers.${PROJECT_NAME}_nginx.priority=1'
- 'traefik.docker.network=proxy'
app:
container_name: "${PROJECT_NAME}_php"
build:
context: ./docker/php
dockerfile: Dockerfile
environment:
PHP_MEMORY_LIMIT: '512M'
COMPOSER_MEMORY_LIMIT: '-1'
user: 501:501
volumes:
- ./:/var/www/vhost/crm:cached
working_dir: /var/www/vhost/crm
ports:
- '9003:9003'
networks:
- crm
redis:
image: redis:latest
container_name: "${PROJECT_NAME}_redis"
ports:
- '6379:6379'
volumes:
- 'crm_redis:/data'
networks:
- crm
mysql:
image: mariadb:latest
container_name: "${PROJECT_NAME}_mysql"
environment:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
MYSQL_DATABASE: '${DB_DATABASE}'
MYSQL_USER: '${DB_USERNAME}'
MYSQL_PASSWORD: '${DB_PASSWORD}'
MYSQL_ROOT_HOST: '%'
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
restart: always
volumes:
- 'crm_mysql:/data'
ports:
- '${FORWARD_DB_PORT:-4406}:3306'
networks:
- crm
traefik:
image: traefik:v2.0
container_name: "${PROJECT_NAME}_traefik"
restart: always
command:
- --entrypoints.web.address=:80
- --providers.docker=true
- --api.insecure=true
- --log.level=debug
volumes:
- '/var/run/docker.sock:/var/run/docker.sock'
ports:
- '80:80'
- '8080:8080'
networks:
- crm
networks:
crm:
driver: bridge
volumes:
crm_mysql:
driver: local
crm_redis:
driver: local
Finally what I like to do with every docker project, is create a makefile - it allows me to have easier and more convienient commands to access what I need when I need it, you do not need to do this yourself but I will include the file here:
.RECIPEPREFIX +=
.DEFAULT_GOAL := help
PROJECT_NAME=jump
include .env
help:
@echo "Welcome to $(PROJECT_NAME) IT Support, have you tried turning it off and on again?"
install:
@composer install
test:
@docker exec $(PROJECT_NAME)_php ./vendor/bin/pest --parallel
coverage:
@docker exec $(PROJECT_NAME)_php ./vendor/bin/pest --coverage
migrate:
@docker exec $(PROJECT_NAME)_php php artisan migrate
seed:
@docker exec $(PROJECT_NAME)_php php artisan db:seed
fresh:
@docker exec crm_php php artisan migrate:fresh
analyse:
./vendor/bin/phpstan analyse --memory-limit=256m
generate:
@docker exec $(PROJECT_NAME)_php php artisan ide-helper:models --write
nginx:
@docker exec -it $(PROJECT_NAME)_nginx /bin/sh
php:
@docker exec -it $(PROJECT_NAME)_php /bin/sh
mysql:
@docker exec -it $(PROJECT_NAME)_mysql /bin/sh
redis:
@docker exec -it $(PROJECT_NAME)_redis /bin/sh
This just contains some command that I find useful while working with docker - your mileage may vary!
If you have any questions, or think this could be simplified in anyway please feel free to reach out on twitter and let me know your thoughts.
If you want to see me walking through this set up, I have a video on youtube where I add this to a project myself: