Home
Dockerize Laravel Application w/ Inertia and Vue

Dockerize Laravel Application w/ Inertia and Vue

Published on April 26, 2024
DevelopmentDockerLaravelVueInertia

This 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:

Terminal window
sudo apt install -y php-cli

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:

Terminal window
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:

Terminal window
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
sudo mkdir -p /usr/local/bin
sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
php -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:

Terminal window
composer global require laravel/installer

Then, let’s check if we have the laravel command on our terminal:

Terminal window
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:

Terminal window
code ~/.zshrc

Add the following lines to the end of the file:

~/.zshrc
# .. other lines
# Composer
export PATH=$PATH:$HOME/.config/composer/vendor/bin

Then, let’s source the file to apply the changes:

Terminal window
source ~/.zshrc

That’s it! The laravel command should now work.

Terminal window
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.

~/projects
mkdir laravel
cd laravel

Use the Laravel Installer to generate a project

~/projects/laravel
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.

~/projects/laravel
cd my-project
code .

Then create a file in the root directory named compose.yaml

Let’s start by defining our top-level name:

~/projects/laravel/my-project/compose.yaml
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:

~/projects/laravel/my-project/compose.yaml
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:

~/projects/laravel/my-project/Dockerfile
# Choose the PHP version you want
FROM php:8.3-apache
ARG domain
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
zip \
unzip
# Clear cache
RUN 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/public
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf
RUN 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 Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Node.js
RUN curl -sL "https://deb.nodesource.com/setup_21.x" | bash -
RUN apt update
RUN 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 uid
RUN useradd -G www-data,root -u $uid -d /home/devuser devuser
RUN mkdir -p /home/devuser/.composer && \
chown -R devuser:devuser /home/devuser

Looks complicated, but it basically does these things:

  1. Get the PHP 8.3 with Apache image from Docker Hub
  2. Install system dependencies
  3. Configure Apache to serve our app
  4. Install PHP Extensions needed by Laravel
  5. Get the latest Composer binary
  6. Install Node.js
  7. 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:

~/projects/laravel/my-project/compose.yaml
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:

~/projects/laravel/my-project/Dockerfile
# Choose the PHP version you want
FROM php:8.3-fpm
# Arguments defined in docker-compose.yml
ARG user
ARG uid
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
zip \
unzip
# Clear cache
RUN 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 Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Node.js
RUN curl -sL "https://deb.nodesource.com/setup_21.x" | bash -
RUN apt update
RUN apt -q -y install nodejs
# Create system user to run Composer and Artisan Commands
RUN useradd -G www-data,root -u $uid -d /home/$user $user
RUN mkdir -p /home/$user/.composer && \
chown -R $user:$user /home/$user
# Set working directory
WORKDIR /var/www
USER $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:

  1. Get the PHP 8.3-fpm image from Docker Hub
  2. Install system dependencies
  3. Install PHP Extensions needed by Laravel
  4. Get the latest Composer binary
  5. Install Node.js
  6. 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:

~/projects/laravel/my-project/.docker/nginx/conf.d/app.conf
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:

~/projects/laravel/my-project/compose.yaml
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:

~/projects/laravel/my-project/compose.yaml
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

~/projects/laravel/my-project/compose.yaml
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

~/projects/laravel/my-project/compose.yaml
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:

~/projects/laravel/my-project/vite.config.ts
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:

Terminal window
APP_URL=http://my-project.localhost
...
DB_CONNECTION=mysql
DB_HOST=my-project-mysql
DB_PORT=3306
DB_DATABASE=my_database
DB_USERNAME=user
DB_PASSWORD=password

Nice! It’s finally time to spin up our services!

~/projects/laravel/my-project
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:

~/projects/laravel/my-project
# Make sure NPM dependencies are installed
docker compose exec app npm install
docker compose exec app npm run dev

You can also run artisan and composer commands using the following:

~/projects/laravel/my-project
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

  1. 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/null
echo "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.list
sudo apt update
  1. Install using APT
Terminal window
sudo apt install -y just
  1. Create a justfile on the root of your project.
~/projects/laravel/my-project/justfile
up:
docker compose up -d
down:
docker compose down
rebuild:
docker compose up -d --build --force-recreate
sh:
docker compose exec -u 1000 app bash
php *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 dev
build:
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.

~/projects/laravel/my-project
just artisan migrate:fresh --seed
just composer require spatie/laravel-permission
just npm run dev
# or
just 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.