juststeveking.uk

Lead Software Engineer at Geomiq .

Published on
Reading time
8 minute read

Laravel subdomains in Docker

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

1nginx:
2 container_name: "${PROJECT_NAME}_nginx"
3 build:
4 context: ./docker/nginx
5 dockerfile: Dockerfile
6 depends_on:
7 - app
8 volumes:
9 - ./:/var/www/vhost/crm:cached
10 - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
11 - ./docker/nginx/conf.d:/etc/nginx/conf.d
12 - ./docker/nginx/ssl:/etc/nginx/ssl
13 working_dir: /var/www/vhost/
14 ports:
15 - '443:443'
16 - '9008:9008'
17 networks:
18 - crm
19 labels:
20 - 'traefik.http.routers.${PROJECT_NAME}_nginx.rule=HostRegexp(`${APP_DOMAIN}`, `{subdomain:[a-z]+.${APP_DOMAIN}}`)'
21 - 'traefik.http.routers.${PROJECT_NAME}_nginx.priority=1'
22 - '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:

1# Offical Docker Image for Nginx
2# https://hub.docker.com/_/nginx
3FROM nginx:alpine
4 
5# Set Current Directory
6WORKDIR /var/www/vhost/

The nginx configuration should be created under: docker/nginx/nginx.conf and contain the following:

1pid /run/nginx.pid;
2worker_processes auto;
3worker_rlimit_nofile 65535;
4 
5events {
6 multi_accept on;
7 worker_connections 65535;
8}
9 
10http {
11 charset utf-8;
12 sendfile on;
13 tcp_nopush on;
14 tcp_nodelay on;
15 server_tokens off;
16 log_not_found off;
17 types_hash_max_size 2048;
18 client_max_body_size 16M;
19 
20 # MIME
21 include mime.types;
22 default_type application/octet-stream;
23 
24 # logging
25 access_log /var/log/nginx/access.log;
26 error_log /var/log/nginx/error.log warn;
27 
28 # SSL
29 ssl_session_timeout 1d;
30 ssl_session_cache shared:SSL:50m;
31 ssl_session_tickets off;
32 
33 # Diffie-Hellman parameter for DHE ciphersuites
34 #ssl_dhparam /etc/nginx/dhparam.pem;
35 
36 # OWASP B (Broad Compatibility) configuration
37 ssl_protocols TLSv1.2 TLSv1.3;
38 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;
39 ssl_prefer_server_ciphers on;
40 
41 # OCSP Stapling
42 ssl_stapling on;
43 ssl_stapling_verify on;
44 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;
45 resolver_timeout 2s;
46 
47 # load configs
48 include /etc/nginx/conf.d/*.conf;
49}

Our sites configuration should be be: docker/nginx/conf.g/site.conf and contain the following:

1server {
2
3 #listen 80;
4 listen [::]:80;
5
6 # For https
7 listen 443 ssl;
8 listen [::]:443 ssl ipv6only=on;
9 ssl_certificate /etc/nginx/ssl/app-cert.pem;
10 ssl_certificate_key /etc/nginx/ssl/app-key.pem;
11
12 root /var/www/vhost/crm/public;
13 index index.php index.html index.htm;
14
15 location / {
16 try_files $uri $uri/ /index.php$is_args$args;
17 }
18
19 location ~ \.php$ {
20 try_files $uri /index.php =404;
21 # We are using our app service container name instead of 127.0.0.1 as our connection
22 fastcgi_pass app:9000;
23 fastcgi_index index.php;
24 fastcgi_buffers 16 16k;
25 fastcgi_buffer_size 32k;
26 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
27 #fixes timeouts
28 fastcgi_read_timeout 600;
29 include fastcgi_params;
30 }
31
32 location ~ /\.ht {
33 deny all;
34 }
35
36 location /.well-known/acme-challenge/ {
37 root /var/www/letsencrypt/;
38 log_not_found off;
39 }
40
41 error_log /var/log/nginx/laravel_error.log;
42 access_log /var/log/nginx/laravel_access.log;
43}

Finally we will need to have our SSL configuration created under: docker/nginx/ssl/openssl.cnf and contain the following:

1[req]
2default_bits = 2048
3default_md = sha256
4encrypt_key = no
5prompt = no
6distinguished_name = subject
7req_extensions = req_ext
8x509_extensions = x509_ext
9
10[ subject ]
11C = Country
12ST = State
13L = Location
14O = Organisation
15OU = Team
16emailAddress = email@example.com
17CN = localhost
18
19[ req_ext ]
20subjectKeyIdentifier = hash
21basicConstraints = CA:FALSE
22keyUsage = digitalSignature, keyEncipherment
23extendedKeyUsage = serverAuth, clientAuth
24subjectAltName = @alternate_names
25nsComment = "OpenSSL Generated Certificate"
26
27[ x509_ext ]
28subjectKeyIdentifier = hash
29authorityKeyIdentifier = keyid,issuer
30basicConstraints = CA:FALSE
31keyUsage = digitalSignature, keyEncipherment
32extendedKeyUsage = serverAuth, clientAuth
33subjectAltName = @alternate_names
34nsComment = "OpenSSL Generated Certificate"
35
36[ alternate_names ]
37DNS.1 = localhost
38IP.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:

1mkcert "*.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:

1app:
2 container_name: "${PROJECT_NAME}_php"
3 build:
4 context: ./docker/php
5 dockerfile: Dockerfile
6 environment:
7 PHP_MEMORY_LIMIT: '512M'
8 COMPOSER_MEMORY_LIMIT: '-1'
9 user: 501:501
10 volumes:
11 - ./:/var/www/vhost/crm:cached
12 working_dir: /var/www/vhost/crm
13 ports:
14 - '9003:9003'
15 networks:
16 - 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:

1# Offical Docker Image for PHP
2# https://hub.docker.com/_/php
3FROM php:8.1-fpm
4 
5# Set Current Directory
6WORKDIR /var/www/vhost/
7 
8# Install dependencies
9RUN apt-get clean && apt-get update && apt-get upgrade -y && apt-get install -y \
10 git \
11 libcurl4-openssl-dev \
12 libonig-dev \
13 libpng-dev \
14 libssl-dev \
15 libicu-dev \
16 libxml2-dev \
17 libzip-dev \
18 unzip \
19 wget \
20 zip \
21 tzdata
22 
23RUN docker-php-ext-configure intl
24# PHP Extensions
25RUN docker-php-ext-install \
26 bcmath \
27 exif \
28 gd \
29 mysqli \
30 opcache \
31 pdo_mysql \
32 pcntl \
33 xml \
34 zip \
35 intl
36 
37# Install Composer from Official Docker Image
38# https://hub.docker.com/_/composer
39COPY --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:

1[opcache]
2opcache.enable=1
3; 0 means it will check on every request
4; 0 is irrelevant if opcache.validate_timestamps=0 which is desirable in production
5opcache.revalidate_freq=0
6opcache.validate_timestamps=1
7opcache.max_accelerated_files=10000
8opcache.memory_consumption=192
9opcache.max_wasted_percentage=10
10opcache.interned_strings_buffer=16
11opcache.fast_shutdown=1
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:

1redis:
2 image: redis:latest
3 container_name: "${PROJECT_NAME}_redis"
4 ports:
5 - '6379:6379'
6 volumes:
7 - 'crm_redis:/data'
8 networks:
9 - 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:

1mysql:
2 image: mariadb:latest
3 container_name: "${PROJECT_NAME}_mysql"
4 environment:
5 MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
6 MYSQL_DATABASE: '${DB_DATABASE}'
7 MYSQL_USER: '${DB_USERNAME}'
8 MYSQL_PASSWORD: '${DB_PASSWORD}'
9 MYSQL_ROOT_HOST: '%'
10 MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
11 command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
12 restart: always
13 volumes:
14 - 'crm_mysql:/data'
15 ports:
16 - '${FORWARD_DB_PORT:-4406}:3306'
17 networks:
18 - 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).

1traefik:
2 image: traefik:v2.0
3 container_name: "${PROJECT_NAME}_traefik"
4 restart: always
5 command:
6 - --entrypoints.web.address=:80
7 - --providers.docker=true
8 - --api.insecure=true
9 - --log.level=debug
10 volumes:
11 - '/var/run/docker.sock:/var/run/docker.sock'
12 ports:
13 - '80:80'
14 - '8080:8080'
15 networks:
16 - 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

1version: '3'
2 
3services:
4 nginx:
5 container_name: "${PROJECT_NAME}_nginx"
6 build:
7 context: ./docker/nginx
8 dockerfile: Dockerfile
9 depends_on:
10 - app
11 volumes:
12 - ./:/var/www/vhost/crm:cached
13 - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
14 - ./docker/nginx/conf.d:/etc/nginx/conf.d
15 - ./docker/nginx/ssl:/etc/nginx/ssl
16 working_dir: /var/www/vhost/
17 ports:
18 - '443:443'
19 - '9008:9008'
20 networks:
21 - crm
22 labels:
23 - 'traefik.http.routers.${PROJECT_NAME}_nginx.rule=HostRegexp(`${APP_DOMAIN}`, `{subdomain:[a-z]+.${APP_DOMAIN}}`)'
24 - 'traefik.http.routers.${PROJECT_NAME}_nginx.priority=1'
25 - 'traefik.docker.network=proxy'
26 
27 app:
28 container_name: "${PROJECT_NAME}_php"
29 build:
30 context: ./docker/php
31 dockerfile: Dockerfile
32 environment:
33 PHP_MEMORY_LIMIT: '512M'
34 COMPOSER_MEMORY_LIMIT: '-1'
35 user: 501:501
36 volumes:
37 - ./:/var/www/vhost/crm:cached
38 working_dir: /var/www/vhost/crm
39 ports:
40 - '9003:9003'
41 networks:
42 - crm
43 
44 redis:
45 image: redis:latest
46 container_name: "${PROJECT_NAME}_redis"
47 ports:
48 - '6379:6379'
49 volumes:
50 - 'crm_redis:/data'
51 networks:
52 - crm
53 
54 mysql:
55 image: mariadb:latest
56 container_name: "${PROJECT_NAME}_mysql"
57 environment:
58 MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
59 MYSQL_DATABASE: '${DB_DATABASE}'
60 MYSQL_USER: '${DB_USERNAME}'
61 MYSQL_PASSWORD: '${DB_PASSWORD}'
62 MYSQL_ROOT_HOST: '%'
63 MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
64 command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
65 restart: always
66 volumes:
67 - 'crm_mysql:/data'
68 ports:
69 - '${FORWARD_DB_PORT:-4406}:3306'
70 networks:
71 - crm
72 
73 traefik:
74 image: traefik:v2.0
75 container_name: "${PROJECT_NAME}_traefik"
76 restart: always
77 command:
78 - --entrypoints.web.address=:80
79 - --providers.docker=true
80 - --api.insecure=true
81 - --log.level=debug
82 volumes:
83 - '/var/run/docker.sock:/var/run/docker.sock'
84 ports:
85 - '80:80'
86 - '8080:8080'
87 networks:
88 - crm
89 
90networks:
91 crm:
92 driver: bridge
93 
94volumes:
95 crm_mysql:
96 driver: local
97 
98 crm_redis:
99 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:

1.RECIPEPREFIX +=
2.DEFAULT_GOAL := help
3PROJECT_NAME=jump
4include .env
5 
6help:
7 @echo "Welcome to $(PROJECT_NAME) IT Support, have you tried turning it off and on again?"
8 
9install:
10 @composer install
11 
12test:
13 @docker exec $(PROJECT_NAME)_php ./vendor/bin/pest --parallel
14 
15coverage:
16 @docker exec $(PROJECT_NAME)_php ./vendor/bin/pest --coverage
17 
18migrate:
19 @docker exec $(PROJECT_NAME)_php php artisan migrate
20 
21seed:
22 @docker exec $(PROJECT_NAME)_php php artisan db:seed
23 
24fresh:
25 @docker exec crm_php php artisan migrate:fresh
26 
27analyse:
28 ./vendor/bin/phpstan analyse --memory-limit=256m
29 
30generate:
31 @docker exec $(PROJECT_NAME)_php php artisan ide-helper:models --write
32 
33nginx:
34 @docker exec -it $(PROJECT_NAME)_nginx /bin/sh
35 
36php:
37 @docker exec -it $(PROJECT_NAME)_php /bin/sh
38 
39mysql:
40 @docker exec -it $(PROJECT_NAME)_mysql /bin/sh
41 
42redis:
43 @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: