Apr 30, 2025
In this guide, we’ll walk through the deployment of a dockerized production server architecture for a Laravel application, complete with services such as Postgres, Nginx, queue workers, Mailpit.
Before proceeding, ensure you have the following:
Here’s a look at the server architecture to support the production deployment of a Laravel 11 application.
The reverse proxy server is listenning on ports 80 and 443 for incoming HTTP requests. The incoming traffic is then pointed to the corresponding application instance running on either the production or staging server. Each application instance then forwards their database requests to the respective database server; production app server will talk to the production database server and staging app server will direct its database calls to the staging database server.
The snippet below shows the folder structure and files needed for this setup. They belong to the same parent directory (monorepo) to faciliate easier Git tracking.
.
├── app
├── docker-compose-app-prod.yml
├── docker-compose-db-prod.yml
├── docker-compose-reverse-proxy.yml
├── docker-compose-app-dev.yml
├── Dockerfile
└── nginx
├── nginx.conf
├── nginx-reverse-proxy.conf
└── nginx-mailpit.conf
A reverse proxy is a server that sits in front of web servers and forwards client (e.g. web browser) requests to those web servers. Reverse proxies are typically implemented to help increase security, performance, and reliability.
Loading balancing - A single origin server can become overwhelmed if there are a large number of users sending requests to application/website running on that single server. To combat this point of vulnerability, the application can be deployed on a pool of servers. A reverse proxy is placed in front the pool of servers and it provides a load balancing solution to distribute the incoming traffic evenly.
Protection from attacks - With a reverse proxy in place, the application/website never reveals the IP address of their origin server(s). As such, attackers would not be able to directly attack the origin servers but rather only be able to target the reverse proxy.
SSL encryption - SSL encryption and decryption can be done on the reverse proxy which can be computationally expensive if performed on the application/website origin server.
Below is the docker-compose-reverse-proxy.yml
file.
services:
nginx:
image: nginx:stable-alpine
container_name: reverse_proxy
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx-reverse-proxy.conf:/etc/nginx/templates/default.conf.template
- ./nginx/ssl:/etc/nginx/ssl
- ./nginx/logs:/var/log/nginx
restart: unless-stopped
Below is the nginx-reverse-proxy.conf
file.
server {
listen 80;
server_name xxx.xx.xxx.xx; # IP of reverse proxy server
location / {
proxy_pass http://xxx.xx.xxx.xx:8000; # IP of app server IP and port number
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
sudo docker compose -f docker-compose-reverse-proxy.yml up -d --build
In this guide, the production application server will be running a dockerizedLaravel 11 web application. Below is the docker-compose-app-prod.yml
file.
services:
app:
container_name: laravel
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
volumes:
- storage:/usr/share/nginx/html/storage:rw
- public:/usr/share/nginx/html/public:rw
networks:
- laravel-network
queue-worker:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
command: php artisan queue:work
environment:
IS_WORKER: "true"
env_file:
- .env
volumes:
- storage:/usr/share/nginx/html/storage:rw
- public:/usr/share/nginx/html/public:rw
nginx:
image: nginx:1-alpine
ports:
- "${APP_PORT}:80"
volumes:
- ./nginx-mailpit.conf:/etc/nginx/templates/default.conf.template
- storage:/usr/share/nginx/html/storage:rw
- public:/usr/share/nginx/html/public:ro
networks:
- laravel-network
mailpit:
image: axllent/mailpit
container_name: mailpit
ports:
- "8025:8025"
- "1025:1025"
environment:
MP_WEBROOT: /mailpit/
restart: unless-stopped
networks:
- laravel-network
volumes:
storage:
public:
networks:
laravel-network:
driver: bridge
Below is the nginx-mailpit.conf
file.
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html/public;
access_log /dev/stdout;
error_log /dev/stderr error;
index index.html index.htm index.php;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location /storage/ {
alias /usr/share/nginx/html/storage/app/public/;
access_log off;
expires max;
add_header Cache-Control "public";
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi.conf;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location /mailpit/ {
proxy_pass http://mailpit:8025/mailpit/;
# configure the websocket
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
sudo docker compose -f docker-compose-app-prod.yml up -d --build
In this guide, the production database server will be hosting a Postgres database running in Docker. Below is the docker-compose-db-prod.yml
file.
services:
postgres:
image: bitnami/postgresql:16.3.0
platform: linux/amd64
ports:
- 5432:5432
restart: always
volumes:
- db-data:/bitnami/postgresql
environment:
- POSTGRESQL_DATABASE=${DB_DATABASE}
- POSTGRESQL_USERNAME=${DB_USERNAME}
- POSTGRESQL_PASSWORD=${DB_PASSWORD}
networks:
- laravel-network
healthcheck:
test:
["CMD", "pg_isready", "-U", "${DB_USERNAME}", "-d", "${DB_DATABASE}"]
interval: 10s
retries: 5
timeout: 5s
start_period: 10s
volumes:
db-data:
networks:
laravel-network:
driver: bridge
sudo docker compose -f docker-compose-db-prod.yml up -d --build
To faciliate an easier local development setup, Docker can also be leveraged. To enable features such as hot reloading, some changes are needed as compared to the code presently in the docker-compose-app-prod.yml
file. The local development environment will be setup in the docker-compose-app-dev.yml
file as shown below.
services:
postgres:
image: bitnami/postgresql:16.3.0
platform: linux/amd64
ports:
- 5432:5432
restart: always
volumes:
- ./db-data:/bitnami/postgresql
environment:
- POSTGRESQL_DATABASE=${DB_DATABASE}
- POSTGRESQL_USERNAME=${DB_USERNAME}
- POSTGRESQL_PASSWORD=${DB_PASSWORD}
networks:
- laravel-network
healthcheck:
test:
["CMD", "pg_isready", "-U", "${DB_USERNAME}", "-d", "${DB_DATABASE}"]
interval: 10s
retries: 5
timeout: 5s
start_period: 10s
app:
container_name: laravel
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- 9002:9001
depends_on:
- postgres
env_file:
- .env
volumes:
- ./:/usr/share/nginx/html # Mount entire project directory for 'hot-reload'
- storage:/usr/share/nginx/html/storage:rw
- public:/usr/share/nginx/html/public:rw
networks:
- laravel-network
queue-worker:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
command: php artisan queue:work
environment:
IS_WORKER: "true"
env_file:
- .env
depends_on:
- postgres
volumes:
- ./:/usr/share/nginx/html # Mount entire project directory for 'hot-reload'
- storage:/usr/share/nginx/html/storage:rw
- public:/usr/share/nginx/html/public:rw
nginx:
image: nginx:1-alpine
ports:
- 80:80
- 443:443
volumes:
- ./nginx.conf:/etc/nginx/templates/default.conf.template
- storage:/usr/share/nginx/html/storage:rw
- public:/usr/share/nginx/html/public:ro
networks:
- laravel-network
mailpit:
image: axllent/mailpit
container_name: mailpit
ports:
- "8025:8025"
- "1025:1025"
environment:
MP_WEBROOT: /mailpit/
restart: unless-stopped
networks:
- laravel-network
volumes:
storage:
public:
db-data:
networks:
laravel-network:
driver: bridge