Skip to content

Instantly share code, notes, and snippets.

@alucard001
Last active May 4, 2024 01:19
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save alucard001/4fc56ba20b58d9b1e69b5e3dce276f6a to your computer and use it in GitHub Desktop.
Save alucard001/4fc56ba20b58d9b1e69b5e3dce276f6a to your computer and use it in GitHub Desktop.
Docker + Laravel + Nginx + Swoole Reverse Proxy Setup

Working example of Laravel (PHP Swoole) + Docker + Nginx, all PHP code in PHP container, Nginx as Reverse proxy

Desired Architecture

  • Nginx as a reverse proxy
    • Resided in a stand-alone docker container
    • No static file, not serving any file in this docker container
  • PHP as a backend laravel server, as another stand-alone docker container
    • Serving both PHP and static files (e.g. jpg, txt, json)
    • All PHP code files are resided in PHP container
  • I use docker-compose.yml to join the above together
  • You can optionally add any MySQL database (e.g. MariaDB) in this .yml file.

Why are you doing this? Your objective?

  • Most of the online tutorials are teaching how to use Nginx + PHP-FPM, which is fine
  • However, if we are using docker, and you want to separate Nginx and PHP into different containers, there is a problem
  • In order for this Nginx + PHP-FPM to work, both of these containers must be able to access the same code directory (e.g. /var/www/html/)
  • This is because, Nginx needs to first see the file first, then use fastcgi_pass to pass the php file to PHP container
    • One thing to point out: php-fpm is a CGI-compatible program, not a server. You cannot view php-fpm as a server
    • which means, you cannot use proxy_pass to pass HTTP request to it, but it is OK when you use fastcgi_pass.
  • In other words, if you want to completely separate these two containers, and connect it via fastcgi_pass, you must copy your PHP code to both Nginx and PHP containers.
    • which is not acceptable (to me)
  • So my objectives are simply:
    • Nginx as reverse proxy, as slim as possible
    • PHP(-FPM) as another separate container, all codes resided here, including static files
  • It is much more acceptable (to me).

Folder structures in this example

<root_dir>
`-- build/
`---- nginx/
`------ default.conf
`---- php/
`------ .env
`------ Dockerfile
`------ Dockerfile_base
`------ php.ini
`-- src/
`---- <laravel source files>
`---- app/
`---- storage/
`---- composer.json
`---- composer.lock
`---- ...etc...
`docker-compose.yml
`README.md

Alternative/Replacement of PHP-FPM? Let's Swoole (Or RoadRunner or OpenSwoole)

  • I use Swoole as a replacement of PHP-fpm, which is better than PHP-fpm.
  • I tried not to describe what is Swoole here, you can read it on your own.
  • But all in all, I use Swoole to replace PHP-FPM
  • From Laravel v10.x, it supports Swoole and RoadRunner through Laravel Octane
    • So I install it as well.

What each files are doing

docker-compose.yml

  • It defines how Nginx container and PHP container works each other
  • Nginx container
    • The structure is simple, I use Nginx-Alpine as base to keep the docker image size small
    • Expose port 80
    • Sync the default.conf to container directory: /etc/nginx/conf.d/default.conf
      • In production environment, it is recommended to use COPY in Dockerfile to copy the config to container
  • PHP container
    • Expose port 9000
    • context: According to docker doc on context, you can view it as the base directory of executing everything.
      • So, from now on, all paths are being referenced from current directory .
      • It takes me quite a lot of time to understand such a simple thing, which is not told/describe/written in docker documentation.
    • dockerfile: The docker file used to setup this container, more on this later
    • container_name: it will be used in build/nginx/default.conf.

build/nginx/default.conf

  • Most of it copied from laravel octane
  • For upstream directive (upstream_backend_php), make sure you are referring to container name backend_php_laravel defined in docker-compose.yml

How to serve static files in PHP container via Nginx Reverse Proxy?

  • First, read this code:
    location ~* \.(jpg|jpeg|png|gif|svg|webp|html|txt|json|ico|css|js)$ {
        expires 1d;
        add_header Cache-Control public;
        access_log off;

        try_files $uri $uri/ @octane;
    }
  • These code means:
    • For files end with jpg|jpeg|png|gif|svg|webp|html|txt|json|ico|css|js (this symbol: | (pipes) means OR)
    • Set expires to 1d, i.e. One day from today
    • Add header Cache-Control public;
    • access_log off: Do not show these files in access log
    • IMPORTANT try_files $uri $uri/ @octane;: try to see if the files exists in local path (i.e. Nginx container), if not, refer to @octane block in this same config file
    • In @octane block (inside server block):
        location @octane {
            set $suffix "";
      
            if ($uri = /index.php) {
                set $suffix ?$query_string;
            }
      
            proxy_http_version 1.1;
            proxy_set_header Http_Host $http_host;
            proxy_set_header Host $host;
            proxy_set_header Scheme $scheme;
            proxy_set_header SERVER_PORT $server_port;
            proxy_set_header REMOTE_ADDR $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
      
            proxy_set_header X-Real-IP $remote_addr;
            proxy_pass http://upstream_backend_php$suffix;
        }
      
    • It sets a lot of proxy headers
    • IMPORTANT proxy_pass: it will route/redirect/send the traffic from nginx to upstream_backend_php block, with $suffix
      • Different from proxy_pass, fastcgi_pass can only be used on FastCGI container (i.e. PHP-fpm).
      • When you use proxy_pass, you can safely assume that you are using reverse proxy.
    • And upstream block (outside server block)
      upstream upstream_backend_php {
          server backend_php_laravel:9000;
      }
    
    • It goes to PHP container named backend_php_laravel port 9000
  • I want to have your attention to these two lines in default.conf:
    location = /favicon.ico { log_not_found off; access_log off; try_files $uri $uri/ @octane;}
    location = /robots.txt { allow all; log_not_found off; access_log off; try_files $uri $uri/ @octane;}
    
    • These two lines means:
      • For favicon.ico:
        • Do not log if not found
        • Do not add access log
        • IMPORTANT: try_files $uri $uri/ @octane;: Try to find this file in nginx container, if it can't be found, go to @octane block
      • For robots.txt:
        • Do not log if not found
        • Do not add access log
        • Allow access
        • IMPORTANT: try_files $uri $uri/ @octane;: Try to find this file in nginx container, if it can't be found, go to @octane block
    • This line try_files $uri $uri/ @octane; are the key to access files in Laravel public directory

How to add/update/remove composer.json without installing any files

  • TL;DR: Using --no-install flag when running composer require <package>
  • Let's assume I am trying to update src/composer.json in this situation.
  • In <root_dir> where it contains src/, I execute this command:
    • docker run --rm --mount type=bind,src=$pwd/src,target=/app composer:latest composer require my_package --no-install
    • --rm: Remove the container after the container finished the task
    • --mount: mount current_directory/src to /app directory inside container
    • composer:latest: Official docker container of composer
    • composer require my_package --no-install: the command to run inside this container. In this case it installs my_package without really installing the files.
    • which means, it just updates the content in composer.json and composer.lock without really downloading the files, which is what we want.
  • Similarly, to update/remove without really downloading the files:
    • Remove: docker run --rm --mount type=bind,src=$pwd/src,target=/app composer:latest composer remove my_package --no-install
    • Update: docker run --rm --mount type=bind,src=$pwd/src,target=/app composer:latest composer update --no-install
  • Just in case you saw error that you did not have certain version of the package, but you want to force update your composer.json, run this:
    • docker run --rm --mount type=bind,src=$pwd/src,target=/app composer:latest composer require my_package --no-install --ignore-platform-reqs
  • To learn the available options of composer command, you can actually print the --help composer menu by this
    • docker run --rm --mount type=bind,src=$pwd/src,target=/app composer:latest composer require --help

build/php/Dockerfile_base

  • This is base because I need to install a lot of PHP extentions, including Swoole in a single PHP docker image
  • As you may also see, I also install gRPC, protobuf, xlswriter, apcu, pcntl, some extentions are required by Swoole (e.g. pcntl)
  • I also install composer in this docker image
  • Then I copy build/php/php.ini to PHP container.
  • To install Swoole, you can refer to Swoole documentation. The steps outline here are only my "history" of installing Swoole.

About composer install in PHP base image: how not to run composer install everytime when updating PHP code in CI/CD?

  • I want to talk more about this part, as it is important for me.
  • Since we are running CI/CD, which means when we did some updates on PHP, usually we need to re-run everything
  • For example in this case, sometimes we need to reinstall everything, especially composer, even if we are just doing minor changes (or even print debug information)
  • And since the whole process of CI/CD takes very long time, I decided to shortern this time by pre-install composer files, i.e. all files inside vendor dir
  • To accomplish this, I reference how we just run npm install once and we don't need subsequent execution everytime we run CI/CD.
    • Reference: https://github.com/jstandish/cached-node-module-build-example/blob/master/DOCKER_BUILD.md
    • It is simply:
      • Copy the composer.json to an empty dir
      • Run composer install inside that directory
      • After that, use move or cp to move the vendor directory, composer.json and composer.lock to target directory
    • Since most of the time, the code in vendor directory will not change, we can make use of docker cache mechanism to speed up the process of updating our PHP code.
  • While I use it in composer.json, I learn it from above link, which means you can do the same thing using npm install

build/php/Dockerfile

  • This is the main Dockerfile used in docker-compose.yml
  • But it refers the image php_base built by Dockerfile_base
  • After running an update, we copy src directory to /var/www/html
  • It will not overwrite our existing files, e.g. vendor, composer.*
  • We also do chown and chmod to /var/www/html/storage directory
  • To enable Swoole, we run php artisan octane:install --server=swoole
  • To start Swoole + Laravel, we run php artisan octane:start --server=swoole --host=0.0.0.0 --port=9000 --log-level=debug
  • Most of the arguments are self-explained, but I want to point out several things
    • --server=swoole: Add this to start swoole server, if you don't, default to roadrunner
    • --host=0.0.0.0: Bind Swoole to all addresses. i.e. Allow Swoole to listen to all addresses. If you don't define it, default to 127.0.0.1
      • If you don't define it, your Nginx container would not be able to access this PHP container
      • Because Swoole only listen to 127.0.0.1, and 127.0.0.1 is not the same as 0.0.0.0.
      • 127.0.0.1 did not listen to all addresses, 0.0.0.0 does.
      • If you don't, you will see HTTP 502 errors, even if you confirm that you open every port.
      • This problem blocks me for 3 days.

To setup everything

All you need is running below command:

  • docker compose down && docker compose build && docker compose up

Known issue

  • .php file cannot be executed in public (including subfolder)
    • which means, if you have a PHP file called images\myfile.php, and you go to http://nginxserver/images/myfile.php, it will return 404 not found.
  • It takes very long time (around 10 mins) to just build php_base docker image, because there are a lot to download.
  • I haven't tried php:fpm or php:cli docker images as base image. You can try if you want to, and let me know if it works.
  • The size of php_base docker images, as of this writing, is 1.41GB.

Hope it helps someone.

# Nginx default.conf
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_min_length 256;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon;
server_tokens off;
client_body_buffer_size 200;
client_max_body_size 200m;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream upstream_backend_php {
server backend_php_laravel:9000;
}
server {
listen 80;
listen [::]:80;
server_name my_backend;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
index index.php index.html index.htm;
charset utf-8;
server_tokens off;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
# SEO trailing slash problem fix
# rewrite ^/(.*)/$ /$1 permanent; # remove trailing slash
# rewrite ^(.*[^/])$ $1/ permanent; # add a trailing slash
############################
# Reference: https://gist.github.com/Ellrion/4eb5df00173f0fb13a76
############################
location ~* \.(jpg|jpeg|png|gif|svg|webp|html|txt|json|ico|css|js)$ {
expires 1d;
add_header Cache-Control public;
access_log off;
try_files $uri $uri/ @octane;
}
location ~ /\.(?!well-known).* {
deny all;
}
# /etc/nginx/global/php-restrictions.conf
# Don't throw any errors for missing favicons and don't display them in the logs
location = /favicon.ico { log_not_found off; access_log off; try_files $uri $uri/ @octane;}
# Don't log missing robots or show them in the nginx logs
location = /robots.txt { allow all; log_not_found off; access_log off; try_files $uri $uri/ @octane;}
# Deny all attempts to access hidden files such as .htaccess, .htpasswd, .DS_Store (Mac).
# Keep logging the requests to parse later (or to pass to firewall utilities such as fail2ban)
location ~ /\. {
deny all;
}
# Deny access to any files with a .php extension in the uploads directory
# Works in sub-directory installs and also in multisite network
# Keep logging the requests to parse later (or to pass to firewall utilities such as fail2ban)
location ~* /(?:uploads|files)/.*\.php$ {
deny all;
}
############################
# Customize
############################
location /index.php {
try_files /not_exists @octane;
}
location / {
if ($request_method !~ ^(GET|POST|HEAD|OPTIONS|PUT|DELETE)$) {
return 405;
}
try_files $uri $uri/ @octane;
}
location @octane {
set $suffix "";
if ($uri = /index.php) {
set $suffix ?$query_string;
}
proxy_http_version 1.1;
proxy_set_header Http_Host $http_host;
proxy_set_header Host $host;
proxy_set_header Scheme $scheme;
proxy_set_header SERVER_PORT $server_port;
proxy_set_header REMOTE_ADDR $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://upstream_backend_php$suffix;
}
}
version: '3.9'
services:
backend_web:
image: nginx:alpine-slim
restart: always
ports:
- '80:80'
container_name: "backend_nginx"
volumes:
# Please update below path as per your environment.
- ./build/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- backend_php
backend_php:
container_name: "backend_php_laravel"
restart: always
build:
# Please update below path as per your environment.
context: .
dockerfile: build/php/Dockerfile
ports:
- '9000:9000'
FROM php_base:latest
RUN apt update -y && apt upgrade -y
WORKDIR /var/www/html
RUN composer update --optimize-autoloader
COPY src/. /var/www/html
COPY build/php/.env.local /var/www/html/.env
RUN php artisan optimize:clear && php artisan optimize
RUN chown -R www-data:www-data /var/www/html/storage
RUN chmod -R 2755 /var/www/html/storage
RUN php artisan octane:install --server=swoole
EXPOSE 9000
CMD ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=9000", "--log-level=debug"]
FROM php:latest
RUN apt update -y && apt upgrade -y
RUN apt install -y \
libfreetype-dev \
libjpeg62-turbo-dev \
libpng-dev \
imagemagick \
libmagickwand-dev imagemagick \
git \
zip unzip \
openssl libssl-dev libcurl4-openssl-dev \
protobuf-compiler \
autoconf zlib1g-dev \
&& pecl install imagick \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd
RUN pecl install redis && docker-php-ext-enable redis
RUN docker-php-ext-install opcache && docker-php-ext-enable opcache
RUN docker-php-ext-install pdo pdo_mysql
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
RUN docker-php-ext-install sockets && docker-php-ext-enable sockets
# https://wiki.swoole.com/#/environment?id=%e5%bf%ab%e9%80%9f%e5%ae%89%e8%a3%85
# https://wiki.swoole.com/#/environment?id=pecl
# https://c-ares.org/, https://blog.csdn.net/flymore96/article/details/127088192
RUN pecl install --configureoptions 'enable-sockets="yes" enable-openssl="yes" enable-http2="yes" enable-mysqlnd="yes" enable-swoole-json="yes" enable-swoole-curl="yes" enable-cares="no"' swoole
# https://wiki.swoole.com/#/environment?id=%e6%b7%bb%e5%8a%a0swoole%e5%88%b0phpini
RUN echo 'extension=swoole.so' >> /usr/local/etc/php/conf.d/swoole.ini
# https://github.com/viest/php-ext-xlswriter
RUN pecl install xlswriter
# https://cloud.google.com/php/grpc
RUN pecl install grpc
RUN echo 'extension=grpc.so' >> /usr/local/etc/php/conf.d/grpc.ini
RUN pecl install protobuf
RUN echo "extension=protobuf.so" >> /usr/local/etc/php/conf.d/protobuf.ini
# https://dev.to/kakisoft/php-docker-how-to-enable-pcntlprocess-control-extensions-1afk
RUN docker-php-ext-configure pcntl --enable-pcntl && docker-php-ext-install pcntl
RUN pecl install apcu
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN rm -rf /var/cache/apt/lists
# Please refer to the README.md for updating these 2 files.
#COPY src/composer.json /tmp
#RUN cd /tmp && composer install && composer update --optimize-autoloader
#RUN mv /tmp/vendor /var/www/html
#RUN cp /tmp/composer.* /var/www/html
#RUN composer clear-cache
COPY build/php/php.ini /usr/local/etc/php/php.ini
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment