
Dockerize Laravel Application w/ Inertia and Vue
Published on April 26, 2024This post is a continuation and basically an application based on my previous post, Flexible Development Environment Using Docker, in which we are going to run a Laravel application with Inertia and Vue.js frontend using our previous setup, which is Docker.
Requirements
PHP CLI (at least version 8.2)
In modern debian-based distros, you can just install it using apt
:
sudo apt install -y php-cli
Note that we are just installing the CLI version of PHP, not the standalone
php
package from APT, since it also ships apache
by default, and we
don’t want apache
to take over our port 80 once it gets installed on our
system. So we should just install the CLI version.
PHP Extensions
Even if we’re gonna run Laravel through Docker, we still need the PHP extensions needed by Laravel for us to generate a project.
To install required PHP extensions by Laravel, just use this command:
sudo apt install openssl php-bcmath php-curl php-json php-mbstring php-mysql php-tokenizer php-xml php-zip
Composer
We also need Composer CLI on our system to easily generate Laravel projects with a single command.
To install Composer, just enter these commands on your terminal:
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"sudo mkdir -p /usr/local/binsudo php composer-setup.php --install-dir=/usr/local/bin --filename=composerphp -r "unlink('composer-setup.php');"
Alright! Let’s generate our Laravel project!
Generate Laravel project
To easily create a Laravel project, let’s first install the Laravel installer globally:
composer global require laravel/installer
Then, let’s check if we have the laravel
command on our terminal:
laravel
zsh: command not found: laravel
Oops! ZSH doesn’t consider our global Laravel installation. Let’s fix it by including the Composer bin to our PATH
variable on our .zshrc
:
Open our .zshrc
file on our editor:
code ~/.zshrc
Add the following lines to the end of the file:
# .. other lines
# Composer export PATH=$PATH:$HOME/.config/composer/vendor/bin
Then, let’s source the file to apply the changes:
source ~/.zshrc
That’s it! The laravel
command should now work.
laravel
Laravel Installer 5.7.2
Usage: command [options] [arguments]
Options: -h, --help Display help for the given command. When no command is given display help for the list command -q, --quiet Do not output any message -V, --version Display this application version --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
Available commands: completion Dump the shell completion script help Display help for a command list List commands new Create a new Laravel application
Nice! Now let’s generate our project. First, let’s create a project directory if you don’t have one:
mkdir projects
cd projects
(Optional) Then create a laravel
directory just to be organized.
mkdir laravel
cd laravel
Use the Laravel Installer to generate a project
laravel new
It will ask you some questions regarding on how you will start your project. Here’s mine:
- What is the name of your project? my-project (Name it whatever you want)
- Would you like to install a starter kit? Laravel Breeze
- Which Breeze stack would you like to install? Vue with Inertia
- Would you like any optional features? Dark mode, TypeScript
- Which testing framework do you prefer? Pest
- Would you like to initialize a Git repository? Yes
- Which database will your application use? MySQL
- Default database updated. Would you like to run the default database migrations? No
- We don’t need to run the default migrations immediately since we’ll setup our MySQL container after the installation.
Alright! Now our project is generated, let’s create our compose.yaml
file!
First, let’s open our project in VSCode.
cd my-project
code .
Then create a file in the root directory named compose.yaml
Let’s start by defining our top-level name
:
name: my-project
Web Server
There are two main options for the web server of our Laravel app, which are Apache
and NGINX
. It of course, depends on your preference and the server you’ll deploy your app.
Apache
Our PHP-Apache app
service looks like this:
services: app: container_name: my-project-app build: context: . # We will use a Dockerfile to build our image args: # This will match our user ID to the container user ID # to prevent permission issues uid: ${UID:-1000} # We need to pass our custom domain for the Apache ServerName domain: my-project.localhost ports: - 80 # Expose port 80 with a random port on our host - 42069:5173 # Expose port 5173 with a static port on our host for Vite HMR environment: # Use our UID and GID for Apache user and group - APACHE_RUN_USER=${UID:-1000} - APACHE_RUN_GROUP=${GID:-1000} labels: - 'traefik.docker.network=mynetwork' - 'traefik.http.routers.my-project-app.rule=Host(`my-project.localhost`)' volumes: - ./:/var/www/html # Mount our project directory into the container networks: - myinternalnetwork # Link our service to the internal network - mynetwork # Link our service to the global network we created depends_on: - mysql # Wait until MySQL is up and running
It’s a pretty basic single service that contains PHP and Apache together. You can also read the comments to know what it does, and you remove the comments after if you want.
The Dockerfile
for this service is this:
# Choose the PHP version you wantFROM php:8.3-apache
ARG domain
# Install system dependenciesRUN apt-get update && apt-get install -y \ git \ curl \ libpng-dev \ libonig-dev \ libxml2-dev \ zip \ unzip
# Clear cacheRUN apt-get clean && rm -rf /var/lib/apt/lists/*
# Apache configs + document root.RUN echo "ServerName ${domain}" >> /etc/apache2/apache2.conf
ENV APACHE_DOCUMENT_ROOT=/var/www/html/publicRUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.confRUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
# mod_rewrite for URL rewrite and mod_headers for .htaccess extra headers like Access-Control-Allow-Origin-RUN a2enmod rewrite headers
# Install PHP extensions (add more as you need)RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
# Get latest ComposerCOPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Node.jsRUN curl -sL "https://deb.nodesource.com/setup_21.x" | bash -RUN apt updateRUN apt -q -y install nodejs
# We need a user with the same UID/GID as the host user# so when we execute CLI commands, all the host file's permissions and ownership remain intact.# Otherwise commands from inside the container would create root-owned files and directories.ARG uidRUN useradd -G www-data,root -u $uid -d /home/devuser devuserRUN mkdir -p /home/devuser/.composer && \ chown -R devuser:devuser /home/devuser
Looks complicated, but it basically does these things:
- Get the PHP 8.3 with Apache image from Docker Hub
- Install system dependencies
- Configure Apache to serve our app
- Install PHP Extensions needed by Laravel
- Get the latest Composer binary
- Install Node.js
- Set some permissions
NGINX
Now for NGINX, it’s quite more complicated. This time we need to have 2 separate services: app
and server
. The app
service will be our PHP service and server
will be NGINX. The services looks like these:
services: app: container_name: my-project-app build: context: . # We will use a Dockerfile to build our image args: # This will match our user ID to the container user ID # to prevent permission issues uid: ${UID:-1000} user: ${USER:-user} # Use any username you want for the default environment: # PHP environment variables - SERVICE_NAME=app - SERVICE_TAGS=dev working_dir: /var/www/html ports: - 42069:5173 # Expose port 5173 with a static port on our host for Vite HMR volumes: - ./:/var/www/html # Mount our project directory into the container networks: - myinternalnetwork # Link our service to the internal network depends_on: - mysql # Wait until MySQL is up and running
server: # We need a separate service for our web server container_name: my-project-server image: nginx:alpine restart: unless-stopped tty: true labels: - 'traefik.docker.network=mynetwork' - 'traefik.http.routers.my-project-server.rule=Host(`my-project.localhost`)' ports: - 80 volumes: - ./:/var/www/html - ./.docker/nginx/conf.d/:/etc/nginx/conf.d/ networks: - myinternalnetwork - mynetwork
This is the Dockerfile
for our app
service:
# Choose the PHP version you wantFROM php:8.3-fpm
# Arguments defined in docker-compose.ymlARG userARG uid
# Install system dependenciesRUN apt-get update && apt-get install -y \ git \ curl \ libpng-dev \ libonig-dev \ libxml2-dev \ zip \ unzip
# Clear cacheRUN apt-get clean && rm -rf /var/lib/apt/lists/*
# Install PHP extensions (add more as you need)RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
# Get latest ComposerCOPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Node.jsRUN curl -sL "https://deb.nodesource.com/setup_21.x" | bash -RUN apt updateRUN apt -q -y install nodejs
# Create system user to run Composer and Artisan CommandsRUN useradd -G www-data,root -u $uid -d /home/$user $userRUN mkdir -p /home/$user/.composer && \ chown -R $user:$user /home/$user
# Set working directoryWORKDIR /var/wwwUSER $user
The Dockerfile
for our app
service is actually quite similar to the Dockerfile
for our Apache service, except that we don’t configure our web server inside this Dockerfile
, since our NGINX service is separated. This Dockerfile
does the following:
- Get the PHP 8.3-fpm image from Docker Hub
- Install system dependencies
- Install PHP Extensions needed by Laravel
- Get the latest Composer binary
- Install Node.js
- Set some permissions
Now, create a .docker
directory to store our service configurations.
For our NGINX configuration, create a app.conf
file on your .docker/nginx/conf.d
directory:
server { listen 80; index index.php index.html; error_log /var/log/nginx/error.log; access_log /var/log/nginx/access.log; root /var/www/html/public;
location ~ \.php$ { try_files $uri =404; fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass app:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; }
location / { try_files $uri $uri/ /index.php?$query_string; gzip_static on; }}
This is a pretty basic NGINX configuration. You can modify it as you want.
Database
The database service for both web servers are the same. We’ll just use MySQL
:
mysql: image: mysql/mysql-server:8.0 container_name: my-project-mysql ports: # Expose port 3306 with a random port on our host # You can also specify a static port like this if you want: "42069:3306" - 3306 environment: # Modify this as you want MYSQL_ROOT_PASSWORD: root MYSQL_ROOT_HOST: '%' MYSQL_DATABASE: my_database MYSQL_USER: user MYSQL_PASSWORD: password MYSQL_ALLOW_EMPTY_PASSWORD: 1 volumes: - myproject_mysql_data:/var/lib/mysql # Persist data between container restarts networks: - myinternalnetwork - mynetwork healthcheck: # Check if MySQL is up and running test: ['CMD', 'mysqladmin', 'ping', '-proot'] retries: 3 timeout: 5s
Alright! That’s our services. Last thing, we need to define the volumes and networks we used:
networks: myinternalnetwork: driver: bridge mynetwork: external: true
volumes: myproject_mysql_data: driver: local
That’s it! Here are the complete compose.yaml
for both web servers:
Apache
name: my-project
services: app: container_name: my-project-app build: context: . # We will use a Dockerfile to build our image args: # This will match our user ID to the container user ID # to prevent permission issues uid: ${UID:-1000} # We need to pass our custom domain for the Apache ServerName domain: my-project.localhost ports: - 80 # Expose port 80 with a random port on our host - 42069:5173 # Expose port 5173 with a static port on our host for Vite HMR environment: # Use our UID and GID for Apache user and group - APACHE_RUN_USER=${UID:-1000} - APACHE_RUN_GROUP=${GID:-1000} labels: - 'traefik.docker.network=mynetwork' - 'traefik.http.routers.my-project-app.rule=Host(`my-project.localhost`)' volumes: - ./:/var/www/html # Mount our project directory into the container networks: - myinternalnetwork # Link our service to the internal network - mynetwork # Link our service to the global network we created depends_on: - mysql # Wait until MySQL is up and running
mysql: image: mysql/mysql-server:8.0 container_name: my-project-mysql ports: # Expose port 3306 with a random port on our host # You can also specify a static port like this if you want: "42069:3306" - 3306 environment: # Modify this as you want MYSQL_ROOT_PASSWORD: root MYSQL_ROOT_HOST: '%' MYSQL_DATABASE: my_database MYSQL_USER: user MYSQL_PASSWORD: password MYSQL_ALLOW_EMPTY_PASSWORD: 1 volumes: - myproject_mysql_data:/var/lib/mysql # Persist data between container restarts networks: - myinternalnetwork - mynetwork healthcheck: # Check if MySQL is up and running test: ['CMD', 'mysqladmin', 'ping', '-proot'] retries: 3 timeout: 5s
networks: myinternalnetwork: driver: bridge mynetwork: external: true
volumes: myproject_mysql_data: driver: local
NGINX
name: my-project
services: app: container_name: my-project-app build: context: . # We will use a Dockerfile to build our image args: # This will match our user ID to the container user ID # to prevent permission issues uid: ${UID:-1000} user: ${USER:-user} # Use any username you want for the default environment: # PHP environment variables - SERVICE_NAME=app - SERVICE_TAGS=dev working_dir: /var/www/html ports: - 42069:5173 # Expose port 5173 with a static port on our host for Vite HMR volumes: - ./:/var/www/html # Mount our project directory into the container networks: - myinternalnetwork # Link our service to the internal network depends_on: - mysql # Wait until MySQL is up and running
server: # We need a separate service for our web server container_name: my-project-server image: nginx:alpine restart: unless-stopped tty: true labels: - 'traefik.docker.network=mynetwork' - 'traefik.http.routers.my-project-server.rule=Host(`my-project.localhost`)' ports: - 80 volumes: - ./:/var/www/html - ./.docker/nginx/conf.d/:/etc/nginx/conf.d/ networks: - myinternalnetwork - mynetwork
mysql: image: mysql/mysql-server:8.0 container_name: my-project-mysql ports: - 3306 # Expose port 3306 with a random port on our host environment: # Modify this as you want MYSQL_ROOT_PASSWORD: root MYSQL_ROOT_HOST: '%' MYSQL_DATABASE: my_database MYSQL_USER: user MYSQL_PASSWORD: password MYSQL_ALLOW_EMPTY_PASSWORD: 1 volumes: - myproject_mysql_data:/var/lib/mysql # Persist data between container restarts networks: - myinternalnetwork - mynetwork healthcheck: # Check if MySQL is up and running test: ['CMD', 'mysqladmin', 'ping', '-proot'] retries: 3 timeout: 5s
networks: myinternalnetwork: driver: bridge mynetwork: external: true
volumes: myproject_mysql_data: driver: local
Now, before spinning up our services, we must update our vite.config.ts
to enable HMR:
import { defineConfig } from 'vite'
export default defineConfig({ // ... your existing config server: { host: '0.0.0.0', hmr: { host: 'my-project.localhost', // This should be the custom domain we used clientPort: 42069, // This should be the static port on our host }, },})
We also need to modify our .env
for our app URL and database credentials:
APP_URL=http://my-project.localhost...DB_CONNECTION=mysqlDB_HOST=my-project-mysqlDB_PORT=3306DB_DATABASE=my_databaseDB_USERNAME=userDB_PASSWORD=password
Nice! It’s finally time to spin up our services!
docker compose up -d
After the containers are built, you should be able to visit your Laravel app using the custom domain you assigned (http://my-project.localhost) in my case.

You can check if the HMR works when you run the Vite dev server:
# Make sure NPM dependencies are installeddocker compose exec app npm install
docker compose exec app npm run dev
You can also run artisan
and composer
commands using the following:
docker compose exec app php artisan [COMMAND]
docker compose exec app composer [COMMAND]
Bonus
Well, it’s not fun to manually type all those commands every time I want to execute some artisan
, composer
, or npm
commands. So I have a simple solution: justfile
!
It’s a command runner that we can use to alias our common commands throughout the project. To know more, you can read visit the repository here.
Installation and Setup
- Set up the APT repository
wget -qO - 'https://proget.makedeb.org/debian-feeds/prebuilt-mpr.pub' | gpg --dearmor | sudo tee /usr/share/keyrings/prebuilt-mpr-archive-keyring.gpg 1> /dev/nullecho "deb [arch=all,$(dpkg --print-architecture) signed-by=/usr/share/keyrings/prebuilt-mpr-archive-keyring.gpg] https://proget.makedeb.org prebuilt-mpr $(lsb_release -cs)" | sudo tee /etc/apt/sources.list.d/prebuilt-mpr.listsudo apt update
- Install using APT
sudo apt install -y just
- Create a
justfile
on the root of your project.
up: docker compose up -ddown: docker compose downrebuild: docker compose up -d --build --force-recreatesh: docker compose exec -u 1000 app bashphp *COMMAND: docker compose exec -u 1000 app php {{COMMAND}}artisan *COMMAND: docker compose exec -u 1000 app php artisan {{COMMAND}}composer *COMMAND: docker compose exec -u 1000 app composer {{COMMAND}}mysql *COMMAND: docker compose exec mysql mysql -uroot -proot {{COMMAND}}node *COMMAND: docker compose exec -u 1000 app node {{COMMAND}}npm *COMMAND: docker compose exec -u 1000 app npm {{COMMAND}}dev: docker compose exec -u 1000 app npm run devbuild: docker compose exec -u 1000 app npm run build
That’s it! You can now just run your commands normally, just prefix it by just
.
just artisan migrate:fresh --seed
just composer require spatie/laravel-permission
just npm run dev# orjust dev
All those commands are just my personal preference to use. It’s up to you what commands you want to alias. Feel free to modify the justfile
!
Conclusion
This is how you dockerize a modern Laravel Application effectively. While these configuration seems like a lot of work, it can save us a lot of time especially in production environments.