Skip to content
KhaiziNam Blog KhaiziNam Blog
Go back
Đọc bằng tiếng Việt

Deploy PHP, Laravel, CodeIgniter on Linux VPS with Nginx + MySQL Docker + SSL 2026

Nguyễn Hữu Khải - khaizinam

A complete step-by-step guide to installing PHP 8.2, Nginx, MySQL via Docker, configuring PHP-FPM, UFW firewall, and Let’s Encrypt SSL to deploy plain PHP, Laravel, or CodeIgniter on an Ubuntu VPS from scratch.

You just spun up a Linux VPS and you’re staring at a blank terminal not knowing where to start? Shared hosting works fine for simple PHP - but the moment you need to control PHP versions, configure MySQL your way, or run a Laravel queue worker, shared hosting falls short. This guide goes straight to hands-on: from your first SSH login to your domain running HTTPS with PHP 8.2, Nginx, MySQL (via Docker), and auto-renewing SSL.

Table of Contents:


1. Why Nginx Instead of Apache for PHP on a VPS?

Apache has been the default web server for PHP for decades - and it still works well - but Nginx has a clear advantage on resource-constrained VPS environments. Nginx handles concurrent requests using an event-driven asynchronous model, consuming significantly less RAM than Apache’s process-per-request model under load. On a 1-2GB RAM VPS, this difference is substantial.

See more: How to Deploy Node.js and React Apps on Linux Hosting with Nginx Reverse Proxy

Nginx doesn’t execute PHP directly - it delegates PHP processing to PHP-FPM (FastCGI Process Manager) over a Unix socket or TCP port. This separation means:


2. Prerequisites


3. Step 1 - SSH In, Update the Server, and Enable UFW Firewall

3.1. SSH into the server
# SSH with password (first login)
ssh root@YOUR_SERVER_IP

# SSH with key (recommended for production)
ssh -i ~/.ssh/your_key.pem ubuntu@YOUR_SERVER_IP
3.2. Update the server and install essential tools
# Update all packages
sudo apt update && sudo apt upgrade -y

# Install essential tools
sudo apt install -y curl git unzip software-properties-common apt-transport-https ca-certificates gnupg
3.3. Configure UFW firewall
# Enable UFW
sudo ufw enable

# Allow SSH (critical - missing this step will lock you out)
sudo ufw allow ssh
sudo ufw allow 22/tcp

# Allow HTTP and HTTPS
sudo ufw allow 'Nginx Full'

# Check status
sudo ufw status verbose

Critical warning: Always run sudo ufw allow ssh BEFORE sudo ufw enable. Enabling UFW without opening the SSH port first will completely lock you out of the server - you’ll need to use your VPS provider’s console to recover.


4. Step 2 - Install PHP 8.2 and Required Extensions

4.1. Add PPA and install PHP 8.2
# Add Ondřej Surý's PHP PPA (most trusted source for up-to-date PHP on Ubuntu)
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update

# Install PHP 8.2 and PHP-FPM
sudo apt install -y php8.2 php8.2-fpm

# Verify version
php -v
4.2. Install required PHP extensions
# Full extension set for plain PHP, Laravel, and CodeIgniter
sudo apt install -y \
  php8.2-cli \
  php8.2-common \
  php8.2-mysql \
  php8.2-pgsql \
  php8.2-sqlite3 \
  php8.2-xml \
  php8.2-xmlrpc \
  php8.2-curl \
  php8.2-gd \
  php8.2-imagick \
  php8.2-mbstring \
  php8.2-zip \
  php8.2-bcmath \
  php8.2-intl \
  php8.2-redis \
  php8.2-opcache

# Start and enable PHP-FPM
sudo systemctl enable php8.2-fpm
sudo systemctl start php8.2-fpm
sudo systemctl status php8.2-fpm
4.3. Install Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
sudo chmod +x /usr/local/bin/composer
composer --version

5. Step 3 - Install Nginx

sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
sudo systemctl status nginx

# Remove the default config - you'll create per-site configs instead
sudo rm /etc/nginx/sites-enabled/default

At this point, visiting http://YOUR_SERVER_IP in a browser should show the Nginx welcome page, confirming it’s running correctly.


6. Step 4 - Install MySQL via Docker (and Why You Should)

You can install MySQL directly with apt install mysql-server - that works perfectly fine. However, running MySQL inside a Docker container has practical advantages, especially when managing multiple projects on a single server:

6.1. Install Docker
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] \
  https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

sudo usermod -aG docker $USER
newgrp docker
docker --version
6.2. Run the MySQL 8 container
# Create a named volume so data survives container restarts
docker volume create mysql8_data

# Run MySQL 8
# - Binds port 33060 on localhost only (not exposed to the internet)
# - Data persisted in docker volume
# - Restarts automatically on server reboot
docker run -d \
  --name mysql8 \
  -e MYSQL_ROOT_PASSWORD=your_strong_root_password \
  -e MYSQL_DATABASE=your_db_name \
  -e MYSQL_USER=your_db_user \
  -e MYSQL_PASSWORD=your_db_password \
  -p 127.0.0.1:33060:3306 \
  -v mysql8_data:/var/lib/mysql \
  --restart always \
  mysql:8.0

Important: The -p 127.0.0.1:33060:3306 flag binds MySQL only to the loopback interface - only processes on the same server can connect. Never use 0.0.0.0:3306 on a production server as it exposes MySQL to the public internet.

6.3. Create a database and user per project
# Connect into the MySQL container
docker exec -it mysql8 mysql -u root -p
-- Inside the MySQL shell
CREATE DATABASE laravel_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'laravel_user'@'%' IDENTIFIED BY 'strong_password_here';
GRANT ALL PRIVILEGES ON laravel_app.* TO 'laravel_user'@'%';
FLUSH PRIVILEGES;
EXIT;

In your Laravel / CodeIgniter .env file:

DB_HOST=127.0.0.1
DB_PORT=33060
DB_DATABASE=laravel_app
DB_USERNAME=laravel_user
DB_PASSWORD=strong_password_here

7. Step 5 - Configure Nginx Server Block for PHP

Nginx doesn’t process PHP - it hands PHP requests to PHP-FPM via fastcgi_pass. This is the PHP equivalent of proxy_pass for Node.js, but using the FastCGI protocol instead of HTTP.

7.1. Create the web directory and upload code
sudo mkdir -p /var/www/yourdomain.com
sudo chown -R $USER:www-data /var/www/yourdomain.com
sudo chmod -R 755 /var/www/yourdomain.com

cd /var/www/yourdomain.com
git clone https://github.com/youruser/your-php-project.git .
7.2. Nginx config for plain PHP
sudo nano /etc/nginx/sites-available/yourdomain.com

Paste the following. This is the HTTP port 80 config - Certbot will add the HTTPS section in the next step:

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    root /var/www/yourdomain.com;
    index index.php index.html index.htm;

    access_log /var/log/nginx/yourdomain.access.log;
    error_log  /var/log/nginx/yourdomain.error.log;

    location / {
        try_files $uri $uri/ =404;
    }

    # Pass all .php requests to PHP-FPM
    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht  { deny all; }
    location ~ /\.env { deny all; }
}
7.3. Nginx config for Laravel
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    # Laravel: document root must point to the public/ subdirectory
    root /var/www/yourdomain.com/public;
    index index.php index.html;

    access_log /var/log/nginx/yourdomain.access.log;
    error_log  /var/log/nginx/yourdomain.error.log;

    location / {
        # Laravel routing depends on this line
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht  { deny all; }
    location ~ /\.env { deny all; }

    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}
7.4. Enable the site and test
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

8. Step 6 - Enable Free HTTPS with Certbot + Let’s Encrypt

Certbot reads your existing Nginx config, adds an HTTPS server block for port 443, and configures an HTTP-to-HTTPS redirect - no manual Nginx editing required.

sudo apt install -y certbot python3-certbot-nginx

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Follow the prompts:

  1. Enter your email for renewal notifications
  2. Agree to the Terms of Service (A)
  3. Select redirect HTTP to HTTPS (option 2) - recommended
# Test auto-renewal (dry run - does not affect the real certificate)
sudo certbot renew --dry-run

# Verify the systemd renewal timer is active
sudo systemctl status certbot.timer

9. Step 7 - Deploy Laravel and CodeIgniter

9.1. Complete Laravel setup
cd /var/www/yourdomain.com
composer install --optimize-autoloader --no-dev
cp .env.example .env
nano .env
php artisan key:generate
php artisan migrate --force
sudo chown -R www-data:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
php artisan config:cache
php artisan route:cache
php artisan view:cache
9.2. Run Laravel Queue Worker with Supervisor
sudo apt install -y supervisor
sudo nano /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/yourdomain.com/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/supervisor/laravel-worker.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*

10. Common Errors and Fixes

502 Bad Gateway: Nginx cannot communicate with PHP-FPM. Check that PHP-FPM is running (sudo systemctl status php8.2-fpm) and verify the socket path in your Nginx config (/var/run/php/php8.2-fpm.sock) matches the actual socket file (ls /var/run/php/).

403 Forbidden: Nginx can’t read your files due to wrong ownership. Fix with sudo chown -R www-data:www-data /var/www/yourdomain.com and ensure directories are 755 and files are 644.

404 on all Laravel/CodeIgniter routes: Missing or incorrect try_files $uri $uri/ /index.php?$query_string directive. Framework routing depends on this to forward all non-static requests to index.php.

PHP can’t connect to MySQL Docker: Verify the port in .env matches the mapped port in your docker run command. Check the container is running with docker ps. Test the connection manually: mysql -h 127.0.0.1 -P 33060 -u your_user -p.

Certbot: “Could not automatically find a matching server block”: Your Nginx config’s server_name doesn’t match the domain you passed to Certbot. Check /etc/nginx/sites-available/yourdomain.com and ensure server_name is correct.

Laravel: “No application encryption key has been specified”: You haven’t run php artisan key:generate, or the .env file is missing the APP_KEY variable.

Permission denied on storage/ or bootstrap/cache/: Run sudo chown -R www-data:www-data storage bootstrap/cache && sudo chmod -R 775 storage bootstrap/cache from the Laravel root directory.


FAQ - Frequently Asked Questions

Should I install MySQL directly or use Docker on a VPS?

Both work. A direct install is simpler if you only have one project. Docker makes more sense when managing multiple projects that need different MySQL versions, or when you want to keep the OS clean and simplify data backup and migration.

What’s the difference between a Unix socket and a TCP port for PHP-FPM?

A Unix socket (unix:/var/run/php/php8.2-fpm.sock) is faster than TCP because it communicates through the kernel without going through the network stack. Use Unix sockets when Nginx and PHP-FPM are on the same server - this is the standard setup. Use TCP (127.0.0.1:9000) only when PHP-FPM runs on a separate server.

Can I run multiple PHP versions on the same server?

Yes. Install PHP 7.4 and PHP 8.2 in parallel from Ondřej’s PPA - each version gets its own PHP-FPM socket. In each site’s Nginx config, simply point fastcgi_pass to the correct socket: php7.4-fpm.sock or php8.2-fpm.sock.

What’s the deployment process for new code?

For plain PHP: upload files via SFTP or run git pull - no server restart needed. For Laravel: git pull && composer install —no-dev && php artisan migrate —force && php artisan config:cache && php artisan route:cache. If using queue workers: sudo supervisorctl restart laravel-worker:* after deployment.

What happens when my Let’s Encrypt certificate expires?

Nothing manual is required. Certbot installs a systemd timer that runs certbot renew twice daily. Certificates are renewed automatically when less than 30 days remain. To verify: sudo certbot renew —dry-run.

Conclusion - Your PHP Production Stack Is Ready

You now have a complete PHP production stack: PHP 8.2 with all required extensions running through PHP-FPM, Nginx as the web server with FastCGI pass, MySQL 8 isolated in a Docker container with persistent volume storage, UFW firewall controlling inbound traffic, and auto-renewing HTTPS via Let’s Encrypt. This stack handles plain PHP, Laravel, CodeIgniter, and any other PHP framework.

Next step: set up automated CI/CD with GitHub Actions - SSH into the server, pull the latest code, run composer install and php artisan migrate automatically on every merge to main. Automating deployments from day one eliminates manual errors and reduces each update to a matter of seconds.

See more: How to Deploy Node.js and React Apps on Linux Hosting with Nginx Reverse Proxy


Share this post:

Related Posts

Why I Quit WordPress for Astro + Cloudflare Pages (And Cut My Hosting Bill to Zero)

I quit WordPress and switched to Astro + Cloudflare Pages + R2 to eliminate almost all hosting costs for my personal blog - no more malware, plugin...

Secrets to Deploying React/Node.js on a 1GB RAM VPS with PM2: Never Run Out of Memory 2026

For web developers, owning a 1GB RAM VPS at a low cost is an ideal starting point. However, in practice, deployment often turns into a "nightmare" when...

How to Deploy Node.js and React Apps on Linux Hosting with Nginx Reverse Proxy (PM2 + SSL Guide 2025)

You built a Node.js API or React app and now you're staring at a blank Linux VPS wondering how to actually get it live. Uploading files to shared hosting...

Optimizing Project Environment Setup Time with Docker for Low-Config VPS

How to optimize project environment setup time with Docker for a 2-Core 4GB RAM VPS. Guide to the smoothest Nginx, PHP, MySQL, and Redis configuration....

Ninety Thousand Acres: How I Turned the Tables on a 1 vs 4 Siege With Pure Psychology

Surrounded by 4 stronger kingdoms in Ninety Thousand Acres? Here's how I used the truce window, the daily attack hours, and pure psychological warfare to...

In Depth Review of Ninety Thousand Acres

Ninety Thousand Acres (Chinese: 九萬畝) is a mobile simulation management and strategy (SLG) game published by DH-Publisher, heavily inspired by the Three...

IT Job Benefits Beyond Salary - What Freshers Always Miss

Salary is just one part of total compensation. Here are the IT job benefits freshers consistently overlook when evaluating an offer - and how to ask without...

Outsource vs Product Company for Junior Devs: Which Should You Choose?

Outsource companies pay 20–35% less but are much easier to get into - product companies offer more depth but set a higher bar. A real comparison to help you...

Gothic Sketch AI Prompt: Turn Your Photo into Sketchbook Art

A complete gothic sketch AI prompt to transform your portrait into dark sketchbook art - copy, paste, done. Works on ChatGPT, Midjourney, and Stable...

Rejecting an IT Job Offer: 5 Red Flags Freshers Must Know

5 red flags in IT job offers every fresher and junior must recognize. Knowing when to say no protects you from wasting 6–12 months in the wrong environment.


Previous Post
Secrets to Deploying React/Node.js on a 1GB RAM VPS with PM2: Never Run Out of Memory 2026
Next Post
Cheap AI Accounts: The Dark Side of MMO Reselling and 4 Hidden Risks You Need to Know (2026)