Back to Blog
devopsPublished August 14, 2026

Ubuntu 24.04 Production Server Setup: A Complete Guide for Laravel Projects

A practical ten-phase guide to provisioning a fresh Ubuntu 24.04 server and deploying Laravel on it: Nginx, PHP 8.4, MySQL, Redis, Supervisor and SSL via Certbot, plus app isolation, task scheduling, automated deploys with GitHub Actions and OPcache tuning.

Ubuntu 24.04 Production Server Setup: A Complete Guide for Laravel Projects

Ubuntu 24.04 Production Server Setup

A professional, reusable server baseline for any Laravel project in production.

This is the baseline I return to when I receive a fresh VPS. I wrote it after repeating the job often enough to forget the small things: a PHP extension, a directory permission, or a worker that was not restarted after deployment. The goal is not to collect commands; it is to leave behind a server that still makes sense months later.

This guide assumes you have a brand-new VPS and sudo access.

Before you begin: choose the execution path

The phases are numbered for reference. For a first deployment, use this practical order:

  1. Provision the machine with phase 1.
  2. Follow phase 2 through .env configuration and APP_KEY generation.
  3. Create the database and its user in phase 4.
  4. Return to phase 2 for the storage link and Nginx, then run migrations at the end of phase 4.
  5. If the server will host several applications, complete phase 3 before configuring cron and workers. For one simple site, you may stay with the deploy user and the default PHP-FPM pool.
  6. Complete phases 5 through 10 in order.

Throughout the examples, myapp is a placeholder application name and yourdomain.com is a placeholder domain. Replace both before running commands.


Phase 1 - Provisioning the Server

Every step in this phase runs once on a fresh server; you do not repeat it for each project.

Step 1 - Update the system

Always start here: update packages, then reboot so the latest kernel is loaded.

sudo apt update && sudo apt upgrade -y
sudo reboot

Step 2 - Essential tools

These packages are the foundation every later stage depends on — from repository management to monitoring and protection utilities.

sudo apt install -y \
software-properties-common \
curl \
wget \
git \
zip \
unzip \
htop \
nano \
vim \
tree \
net-tools \
ca-certificates \
apt-transport-https \
lsb-release \
gnupg \
fail2ban \
ufw

Step 3 - Add the PHP repository

The ondrej/php PPA is what makes it possible to install several PHP versions side by side on the same machine.

sudo add-apt-repository ppa:ondrej/php -y
sudo apt update

Step 4 - Install Nginx

sudo apt install nginx -y

sudo systemctl enable nginx
sudo systemctl start nginx

Step 5 - Install PHP

We install three versions together, because legacy projects may still require 8.2 while new ones run on 8.4.

PHP 8.2

sudo apt install -y \
php8.2-fpm php8.2-cli php8.2-common \
php8.2-bcmath php8.2-bz2 php8.2-curl \
php8.2-gd php8.2-imagick php8.2-intl \
php8.2-mbstring php8.2-mysql \
php8.2-opcache php8.2-readline \
php8.2-redis php8.2-soap php8.2-xml \
php8.2-zip

PHP 8.3

sudo apt install -y \
php8.3-fpm php8.3-cli php8.3-common \
php8.3-bcmath php8.3-bz2 php8.3-curl \
php8.3-gd php8.3-imagick php8.3-intl \
php8.3-mbstring php8.3-mysql \
php8.3-opcache php8.3-readline \
php8.3-redis php8.3-soap php8.3-xml \
php8.3-zip

PHP 8.4

sudo apt install -y \
php8.4-fpm php8.4-cli php8.4-common \
php8.4-bcmath php8.4-bz2 php8.4-curl \
php8.4-gd php8.4-imagick php8.4-intl \
php8.4-mbstring php8.4-mysql \
php8.4-opcache php8.4-readline \
php8.4-redis php8.4-soap php8.4-xml \
php8.4-zip

Step 6 - Make PHP 8.4 the default

sudo update-alternatives --install /usr/bin/php php /usr/bin/php8.2 82
sudo update-alternatives --install /usr/bin/php php /usr/bin/php8.3 83
sudo update-alternatives --install /usr/bin/php php /usr/bin/php8.4 84

sudo update-alternatives --config php

Select:

PHP 8.4

Note: this changes the CLI version only. The PHP version that serves each site is decided by the fastcgi_pass socket in that site's Nginx configuration.


Step 7 - Composer

cd /tmp

curl -sS https://getcomposer.org/installer -o composer-setup.php

EXPECTED_CHECKSUM="$(curl -sS https://composer.github.io/installer.sig)"
ACTUAL_CHECKSUM="$(php -r "echo hash_file('sha384', 'composer-setup.php');")"

if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
    >&2 echo 'ERROR: Invalid Composer installer checksum'
    rm composer-setup.php
    exit 1
fi

php composer-setup.php

sudo mv composer.phar /usr/local/bin/composer

rm composer-setup.php

composer --version

Step 8 - Node.js 22 LTS

Required for building front-end assets with Vite and npm.

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -

sudo apt install -y nodejs

node -v
npm -v

Step 9 - MySQL

sudo apt install mysql-server -y

sudo systemctl enable mysql

sudo systemctl start mysql

After installation, run MySQL's interactive hardening script to remove insecure defaults:

sudo mysql_secure_installation

Remove anonymous users and the test database, and disable remote root login. Local administration remains available through sudo mysql at this point; phase 4 explicitly assigns the root password.


Step 10 - Redis

Used in Laravel for cache, sessions and queues.

sudo apt install redis-server -y

sudo systemctl enable redis-server

sudo systemctl start redis-server

Step 11 - Supervisor

Keeps queue:work processes alive and restarts them automatically.

sudo apt install supervisor -y

Step 12 - Certbot

For free Let's Encrypt SSL certificates with automatic renewal.

sudo apt install certbot python3-certbot-nginx -y

Step 13 - Firewall

Important: allow OpenSSH before enabling the firewall, or you will lock yourself out of the server.

sudo ufw allow OpenSSH

sudo ufw allow 'Nginx Full'

sudo ufw enable

Step 14 - Fail2Ban

Bans IP addresses that repeatedly attempt to brute-force your login.

sudo systemctl enable fail2ban

sudo systemctl start fail2ban

Step 15 - Project structure

sudo mkdir -p /var/www

cd /var/www

sudo mkdir myapp

Step 16 - Deployment user

Never deploy as root. Create a dedicated deploy user and add it to the www-data group.

sudo adduser deploy

sudo usermod -aG www-data deploy

Step 17 - Project permissions

sudo chown deploy:www-data /var/www

sudo chmod 2775 /var/www

Step 18 - Git

git --version

Pull-only deployments do not need a Git author name or email. Configure those only if this server will create commits, and do it as the user who will create them.


Step 19 - Verify

A final quick pass to confirm everything is installed and running:

nginx -v

php -v

php8.2 -v

php8.3 -v

php8.4 -v

mysql --version

redis-server --version

composer --version

node -v

npm -v

Final server layout

Ubuntu 24.04
│
├── Nginx
├── PHP 8.2
├── PHP 8.3
├── PHP 8.4
├── Composer
├── Node.js 22 LTS
├── MySQL 8
├── Redis
├── Supervisor
├── Certbot
├── UFW
├── Fail2Ban
│
└── /var/www
    └── myapp

Phase 2 - Installing the Laravel App (Basic Path)

This path fits a server hosting one application. Run it as deploy, not root. If the machine will host several applications, finish this section first and then move the app to its dedicated user in phase 3.

Step 1 - Generate an SSH key

This key is what the server uses to authenticate against GitHub when pulling the project:

ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""

Step 2 - Display the public key

cat ~/.ssh/id_ed25519.pub

Copy the output and add it to your GitHub repository under:

Settings → Deploy keys → Add deploy key

Use a repository-scoped deploy key rather than adding the key to your personal account, so the server never gains access to all of your repositories.

Step 3 - Give the deploy user ownership of the web root

sudo chown deploy:deploy /var/www

Step 4 - Clone the repository

cd /var/www

git clone git@github.com:username/repository.git myapp

cd myapp

Step 5 - Install Composer dependencies

On production we skip dev packages and optimize the autoloader:

composer install --no-dev --optimize-autoloader

Step 6 - Configure the environment file

cp .env.example .env

nano .env

Set APP_ENV=production, APP_DEBUG=false, and your database credentials.

Step 7 - Generate the application key

php artisan key:generate

Create the storage symlink. Run migrations later, after creating the database in phase 4:

php artisan storage:link

Step 8 - Set application ownership

Keep the source owned by deploy so deployments continue to work. PHP-FPM only needs write access to Laravel's runtime directories:

sudo chown -R deploy:www-data /var/www/myapp
sudo chown -R www-data:www-data /var/www/myapp/storage /var/www/myapp/bootstrap/cache
sudo find /var/www/myapp/storage /var/www/myapp/bootstrap/cache -type d -exec chmod 775 {} \;
sudo find /var/www/myapp/storage /var/www/myapp/bootstrap/cache -type f -exec chmod 664 {} \;

Do not recursively make the whole project writable by the web-server user.

Step 9 - Create the Nginx configuration

sudo nano /etc/nginx/sites-available/myapp

Paste the following server block:

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    root /var/www/myapp/public;

    index index.php;
    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
        fastcgi_index index.php;
        include fastcgi.conf;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Note that fastcgi_pass is where you pin the PHP version for this specific site.

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/

Step 11 - Remove the default site

sudo rm /etc/nginx/sites-enabled/default

Step 12 - Test the config and reload Nginx

Always test first, so a typo does not take the service down:

sudo nginx -t

sudo systemctl reload nginx

Step 13 - Enable SSL

Defer certificate issuance until phase 8, after the final domain and catch-all Nginx blocks are in place.


Phase 3 - Isolating Applications

So far everything runs as www-data inside /var/www. That is acceptable for a single site, but it becomes dangerous once you host several projects on the same server: a vulnerability in one site grants full access to the files of every other site, because they all belong to the same user.

The fix is to isolate each application under its own user and run PHP-FPM as that user. The examples below use the project name myapp — replace it with your own.

Step 1 - Create a dedicated user for the app

sudo adduser myapp

Step 2 - Move the application into the user's directory

Instead of the shared /var/www, each app gets its own directory under /home:

sudo mv /var/www/myapp /home/myapp/www

Step 3 - Change directory ownership

sudo chown -R myapp:myapp /home/myapp/www

Step 4 - Add www-data to the app's group

Nginx runs as www-data and needs read access to the static files inside the project:

sudo usermod -aG myapp www-data

Then make sure www-data can traverse the home directory:

sudo chmod 750 /home/myapp

Step 5 - Create a dedicated PHP-FPM pool

This is the step that actually creates the isolation: PHP processes now run as myapp instead of www-data.

Do not edit the shared www.conf, because that would move every site using the default pool to the same application user. Create a complete pool for this app instead:

sudo nano /etc/php/8.4/fpm/pool.d/myapp.conf
[myapp]
user = myapp
group = myapp
listen = /run/php/php8.4-fpm-myapp.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

chdir = /

Tune pm.max_children after measuring each PHP worker's memory usage; 10 is only a conservative starting point.

Step 6 - Update the Nginx configuration

After moving the project from /var/www/myapp to /home/myapp/www, the site will not work until its Nginx configuration is updated. Open the site file:

sudo nano /etc/nginx/sites-available/myapp

Replace the old document root:

root /var/www/myapp/public;

with the new path:

root /home/myapp/www/public;

Then replace the default PHP-FPM socket:

fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;

with the application's dedicated socket:

fastcgi_pass unix:/run/php/php8.4-fpm-myapp.sock;

Save the file. There is no need to create another Nginx file or symlink because this edits the already enabled site configuration.

Step 7 - Restart the services

Test the Nginx config first, then reload both services:

sudo nginx -t

sudo systemctl restart nginx

sudo systemctl restart php8.4-fpm

Step 8 - Deploy as the application user

From now on, log in as myapp when updating the project:

cd /home/myapp/www

git pull --ff-only origin main

Generate a new SSH key for myapp and register it as a repository deploy key. The earlier key belongs to deploy and does not move with the project files.

What this isolation provides

  • A vulnerability in one application does not expose the files of the others.
  • Each application owns its files, so permissive 777 modes are unnecessary.
  • Resource usage becomes traceable: htop tells you exactly which app's user is consuming CPU.

Phase 4 - Setting Up the Database

MySQL was installed back in step 9 of phase 1, but the application still needs its own database and its own user. Never point your app at the root account.

If you are following the guide in order, complete this phase before running php artisan migrate in phase 2 — migrations need the database to already exist.

MySQL was already secured with sudo mysql_secure_installation in phase 1; do not run it a second time here.

Step 1 - Log into MySQL

On Ubuntu the root user authenticates through the system socket, so no database password is needed:

sudo mysql

Step 2 - Set the root password

ALTER USER 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY 'your_password';

This changes root authentication from the Unix socket to a password. Exit MySQL with exit, then use mysql -u root -p instead of sudo mysql for subsequent logins.

Step 3 - Create the database

CREATE DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Use utf8mb4, not utf8. MySQL's utf8 stores only three bytes per character, so it cannot hold emoji and some symbols — a common source of Incorrect string value errors when saving user input. utf8mb4 is also Laravel's own default.

Step 4 - Create the application user

CREATE USER 'myapp'@'localhost' IDENTIFIED BY 'your_password';

Step 5 - Grant privileges

Grant full privileges on that one database only — never on all of them:

GRANT ALL PRIVILEGES ON myapp.* TO 'myapp'@'localhost';

Step 6 - Flush privileges

FLUSH PRIVILEGES;

Then exit:

EXIT;

Step 7 - Confirm the PHP MySQL extension

It was installed with the PHP packages in phase 1; to confirm:

php -m | grep mysql

If it is missing:

sudo apt install php8.4-mysql -y

sudo service php8.4-fpm restart

Step 8 - Point the app at the database

Update the .env file in your project:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=myapp
DB_PASSWORD=your_password

Step 9 - Run the migrations

php artisan migrate --force

--force is required on production, where Laravel otherwise asks for interactive confirmation before running migrations.


Phase 5 - Task Scheduling

Laravel needs exactly one entry in the operating system's scheduler: schedule:run. It is invoked every minute, and Laravel then decides internally which of the tasks defined in routes/console.php are actually due. You never add a cron line per task.

From this phase onward, examples assume that you selected the isolated setup in phase 3, so they use myapp and /home/myapp/www. If you kept the basic path, substitute deploy and /var/www/myapp.

Step 1 - Switch to the application user

The cron job must run as the user that owns the application; otherwise log and cache files are created with the wrong ownership:

sudo -iu myapp

Step 2 - Open the user's crontab

crontab -e

Step 3 - Add the scheduler entry

Append this line to the file:

* * * * * /usr/bin/php8.4 /home/myapp/www/artisan schedule:run >> /home/myapp/www/storage/logs/cron.log 2>&1

Breaking the line down:

  • * * * * * — run every minute, the only frequency Laravel needs.
  • /usr/bin/php8.4 — the full path to the interpreter. Cron does not inherit your PATH, so a bare php may fail or resolve to a different version.
  • >> ... cron.log — append output to a log file.
  • 2>&1 — send error output to that same file; without it, failures disappear silently.

Save and exit, then confirm the entry was registered:

crontab -l

Step 4 - Verify it runs

Wait a minute, then watch the log:

tail -f /home/myapp/www/storage/logs/cron.log

You can also run the command manually first:

php /home/myapp/www/artisan schedule:run

If you hit a permissions error on storage, confirm that the myapp user owns the directory as configured in phase 3.

Step 5 - Rotate the log file

cron.log grows forever and will eventually fill the disk. Add a rotation config:

sudo nano /etc/logrotate.d/myapp-cron

Paste:

/home/myapp/www/storage/logs/cron.log {
    weekly
    rotate 4
    compress
    missingok
    notifempty
    create 0640 myapp myapp
}

Simpler alternative: redirect to >> /dev/null 2>&1 in the cron line and rely on Laravel's own logs — but then you lose any error that occurs before the application boots.


Phase 6 - Redis and Queue Workers

Sending mail, processing images and calling external services are good candidates for a queue. Redis stores the queue, workers execute its jobs, and Supervisor keeps those worker processes running.

Step 1 - Verify Redis

Redis was installed in step 10 of phase 1. Confirm it responds:

redis-cli ping

The reply should be:

PONG

Step 2 - Verify the Redis PHP extension

php -m | grep redis

Or test it directly — this prints nothing on success and throws on failure:

php -r "new Redis();"

If it is missing:

sudo apt install php8.4-redis -y

sudo service php8.4-fpm restart

Step 3 - Point Laravel at Redis

In .env:

QUEUE_CONNECTION=redis
CACHE_STORE=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

New Laravel applications normally already contain a migration for failed_jobs. If your project does not, generate it with the current Artisan command, then migrate:

php artisan make:queue-failed-table

php artisan migrate --force

Step 4 - Create the Supervisor config

Supervisor was installed in step 11 of phase 1. Move into its config directory and create a file for the app:

cd /etc/supervisor/conf.d

sudo nano myapp-workers.conf

Paste:

[program:myapp-workers]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php8.4 /home/myapp/www/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
directory=/home/myapp/www
user=myapp
numprocs=3
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
redirect_stderr=true
stdout_logfile=/home/myapp/www/storage/logs/workers.log
stopwaitsecs=3600

The keys that matter most:

  • numprocs=3 — how many workers run in parallel. Start small and scale with queue depth and CPU cores.
  • user=myapp — workers run as the app owner, consistent with the isolation from phase 3.
  • autorestart=true — restart on crash, which is the whole reason Supervisor is here.
  • stopwaitsecs=3600 — gives a running job time to finish before Supervisor forces the process to stop. Set it above the timeout of your longest job.
  • --max-time=3600 — the worker retires itself every hour and Supervisor restarts it, a simple guard against memory leaks.

Step 5 - Create the log file and fix its ownership

sudo touch /home/myapp/www/storage/logs/workers.log

sudo chown myapp:myapp /home/myapp/www/storage/logs/workers.log

Step 6 - Load the new config

sudo supervisorctl reread

sudo supervisorctl update
  • reread picks up new config files.
  • update actually starts the added or changed programs.

Step 7 - Check the workers

sudo supervisorctl status myapp-workers:*

You should see three processes in the RUNNING state. To follow the log:

tail -f /home/myapp/www/storage/logs/workers.log

Step 8 - Restart workers after every deploy

A worker is a long-lived process holding your code in memory, so it will not see your new changes until it restarts. Add this to the end of your deploy script:

php artisan queue:restart

The command asks each worker to finish its current job and exit gracefully; Supervisor then brings them back up running the new code.


Phase 7 - Configuring Domain Names

When a request arrives, Nginx looks for a server block whose server_name matches the requested domain. If nothing matches, the request falls through to the default server. The practical consequence: any domain pointed at your server's IP — including one you do not own — can end up serving your site. This phase fixes that.

Step 1 - Remove the default_server flag from your site

sudo nano /etc/nginx/sites-available/myapp

Make sure the listen directives do not carry the default_server flag:

listen 80;
listen [::]:80;

Your site should answer for its own domain via server_name — it should not be the catch-all for every unknown request.

Step 2 - Create a default block that swallows unknown requests

sudo nano /etc/nginx/sites-available/default

Paste:

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    server_name _;

    return 444;
}
  • default_server — this block receives every request that matches no configured domain.
  • server_name _ — a dummy name that can never match a real domain.
  • return 444 — an Nginx-specific code that closes the connection with no response. Use return 404 instead if you prefer an explicit reply.

Step 3 - Enable the default block

sudo ln -s /etc/nginx/sites-available/default /etc/nginx/sites-enabled/

sudo nginx -t

sudo service nginx reload

If you removed the default symlink back in phase 2, this restores it — but now pointing at a config we wrote ourselves.

Step 4 - Canonicalise the domain: www to non-www

Serving the site at both www.yourdomain.com and yourdomain.com means two URLs for the same content, which splits search-engine indexing and confuses sessions and cookies. Pick one form and redirect the other to it.

Open your site config:

sudo nano /etc/nginx/sites-available/myapp

Drop www.yourdomain.com from the server_name of the main block, leaving:

server_name yourdomain.com;

Then add a dedicated redirect block in the same file:

server {
    listen 80;
    listen [::]:80;

    server_name www.yourdomain.com;

    return 301 http://yourdomain.com$request_uri;
}

Note the $request_uri at the end. Without it, a visitor on www.yourdomain.com/blog/post lands on the homepage and loses the page they asked for.

Step 5 - Reload and verify

sudo nginx -t

sudo service nginx reload

Test the redirect:

curl -I http://www.yourdomain.com

You should see:

HTTP/1.1 301 Moved Permanently
Location: http://yourdomain.com/

Once SSL is enabled, Certbot rewrites these blocks so the redirect targets https://. Run certbot --nginx against both domains so the certificate covers the www form too.


Phase 8 - Enabling SSL

With the Nginx configuration settled on port 80, it is time to encrypt the connection. We use Certbot, installed back in step 12 of phase 1 — it issues the certificate, edits the Nginx config, and renews the certificate automatically.

Ground rule: do not hand-write listen 443 ssl blocks before running Certbot. Let your HTTP configuration work first and let Certbot add the SSL parts. A half-written 443 block confuses Certbot and can stop it from completing.

Step 1 - Confirm the DNS records

First, every domain you want to secure must resolve to your server's IP through an A record. Let's Encrypt validates ownership by actually visiting the domain, so the process fails if DNS is not ready.

dig +short yourdomain.com

dig +short www.yourdomain.com

Both should print your server's IP address.

Step 2 - Verify the Nginx config

sudo nginx -t

sudo systemctl reload nginx

Also confirm that Nginx Full is allowed in the firewall (configured in step 13 of phase 1), since port 443 has to be open.

Step 3 - Issue the certificate

The main domain and its www form in a single certificate:

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

And any separate subdomain — an admin panel, for instance — that has its own Nginx config file:

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

Group every domain served by one config file into a single certbot command. Domains with separate config files need separate commands.

Step 4 - Choose the HTTPS redirect

Certbot asks whether to redirect HTTP traffic to HTTPS. Choose the redirect option. Certbot then adds, automatically:

  • listen 443 ssl blocks with the certificate and key paths.
  • A 301 redirect from port 80 to HTTPS.

Your sites are then reachable at:

https://yourdomain.com
https://www.yourdomain.com
https://admin.yourdomain.com

Step 5 - Verify the certificates

sudo nginx -t

sudo systemctl reload nginx

sudo certbot certificates

The last command lists every certificate with the domains it covers and its expiry date.

Step 6 - Test automatic renewal

Let's Encrypt certificates are valid for only 90 days, so automatic renewal is not optional. Test it without issuing a real certificate:

sudo certbot renew --dry-run

Step 7 - Check the renewal timer

Certbot installs a systemd timer that checks certificates twice a day and renews anything close to expiry:

sudo systemctl status certbot.timer

It should report active (waiting). To see the next scheduled run:

systemctl list-timers certbot.timer

If you added the default_server block in phase 7, make sure it does not intercept the /.well-known/acme-challenge/ path — otherwise renewal fails silently 90 days later, a failure you only discover when the site goes down.


Phase 9 - Automatic Deployments

The final step: make git push the only thing you do to deploy. We give GitHub Actions its own SSH key into the server and add a deploy script that runs on every push to the main branch.

Step 1 - Generate a deployment key pair

On your local machine, create a key dedicated to GitHub Actions — never reuse your personal key:

ssh-keygen -t ed25519 -C "github-actions" -f ./github-actions -N ""

This produces two files: github-actions (the private key) and github-actions.pub (the public key).

Important warning: do not generate the key inside your project directory (for example ./storage/key), and never commit it to Git. A private key leaked into a repository is full access to your server. Create it in a temporary directory outside the project and delete it locally once it is stored in GitHub.

Step 2 - Copy the public key to the server

Print the public key:

cat github-actions.pub

Then add it to the application user's authorized_keys on the server:

sudo -iu myapp

mkdir -p ~/.ssh

nano ~/.ssh/authorized_keys

Paste the key on its own line, then fix the permissions:

chmod 700 ~/.ssh

chmod 600 ~/.ssh/authorized_keys

SSH may reject a key whose files are too permissive. Use 700 on the directory and 600 on the file.

Step 3 - Test the connection

From your local machine:

ssh -i ./github-actions myapp@YOUR_SERVER_IP

You should land in a shell with no password prompt. If this works, GitHub Actions will work.

Step 4 - Create the deploy script on the server

Rather than putting every command inside the workflow file, keep them in a script on the server — you can then change the deploy steps without touching the repository.

nano /home/myapp/deploy.sh

Paste:

#!/bin/bash
set -e

cd /home/myapp/www

git pull --ff-only origin main

composer install --no-dev --optimize-autoloader --no-interaction

php artisan migrate --force

php artisan config:cache
php artisan route:cache
php artisan view:cache

npm ci
npm run build

php artisan queue:restart

Make it executable:

chmod +x /home/myapp/deploy.sh
  • set -e — abort on the first error instead of finishing a deploy in a half-applied state.
  • queue:restart — required so workers pick up the new code, as covered in phase 6.
  • The cache commands run after the pull, since they freeze the current config and routes.

Step 5 - Add the secrets in GitHub

In your GitHub repository:

Settings → Secrets and variables → Actions → New repository secret

Add four secrets:

  • SSH_PRIVATE_KEY — the full contents of the github-actions file, including the BEGIN and END lines.
  • SSH_HOST — your server's IP address.
  • SSH_USER — the application user, i.e. myapp.
  • SSH_FINGERPRINT — the server's SHA-256 ED25519 host-key fingerprint. Obtain it from the server console with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256 and copy only the SHA256:... value.

Step 6 - Create the workflow file

In your project, create .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@v1.2.2
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          fingerprint: ${{ secrets.SSH_FINGERPRINT }}
          script: bash /home/myapp/deploy.sh

Step 7 - Try it

Push any change to the main branch:

git push origin main

Then follow the run in the Actions tab of your repository. When something fails, the full script output is in that step's log.

Run bash /home/myapp/deploy.sh manually on the server once before wiring it to GitHub. That separates script errors from connection errors instead of leaving you chasing both at the same time.


Phase 10 - Tuning OPcache

For each request, PHP compiles source files into opcodes before execution. OPcache retains that result in memory and reduces repeated work on later requests, making it one of the simplest PHP production optimizations.

Step 1 - Open the configuration file

sudo nano /etc/php/8.4/fpm/php.ini

Step 2 - Set the OPcache values

Find the [opcache] section and set:

opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1

What matters here:

  • memory_consumption=256 — a reasonable starting point for a medium application, not a universal value. Watch free OPcache memory and adjust it to the project and available RAM.
  • max_accelerated_files=20000 — how many files can be cached. A Laravel app with dependencies passes 10,000 files easily, and exceeding the limit means files get evicted.
  • save_comments=1 — retains docblocks for compatibility with packages that read metadata from them.
  • validate_timestamps=0 — the most impactful setting, and the most dangerous. Read the next step before enabling it.

Step 3 - Understanding validate_timestamps

With validate_timestamps=1 (the default), PHP checks each file's modification time on every request to see whether it changed. That is convenient in development and wasteful in production.

Set it to 0 and PHP stops checking entirely — faster, but the server will not see any code you deploy until the cache is cleared. Therefore:

If you set validate_timestamps=0, reloading PHP-FPM after each deploy stops being optional. Without it your site keeps serving the old code after a deploy — a genuinely confusing problem that eats hours.

A middle ground, if you would rather avoid that coupling:

opcache.validate_timestamps=1
opcache.revalidate_freq=60

That checks for changes at most once every 60 seconds instead of on every request.

Step 4 - Restart PHP-FPM

sudo service php8.4-fpm restart

restart versus reload: the former kills all workers and starts them again, briefly interrupting in-flight requests. reload re-reads the configuration and clears OPcache gracefully with no downtime — the right choice after each deploy.

Step 5 - Let the app user reload PHP-FPM

The deploy script runs as myapp, a user with no sudo rights. Grant it one narrowly scoped exception:

sudo visudo

Add at the end of the file:

myapp ALL=(ALL) NOPASSWD: /usr/sbin/service php8.4-fpm reload

Be precise with the path and the arguments. Never grant NOPASSWD on a bare /usr/sbin/service, since that effectively hands the user control over every system service.

Then append this line to the deploy.sh script created in phase 9:

sudo service php8.4-fpm reload

Step 6 - Verify OPcache is working

From inside the application (temporarily, in routes/web.php):

dd(opcache_get_status());

Or straight from the command line:

sudo php-fpm8.4 -i | grep opcache.enable

In the opcache_get_status() output, watch these in particular:

  • opcache_enabled — must be true.
  • memory_usage.free_memory — if it approaches zero, the allocated memory is too small.
  • opcache_statistics.opcache_hit_rate — should exceed 99% in production. A low rate means the cache is being flushed constantly.
  • opcache_statistics.num_cached_scripts — if it nears max_accelerated_files, raise the limit.

Remove the temporary diagnostic route immediately after checking it; exposing OPcache status publicly leaks server details. The FPM php.ini does not apply to the CLI, which is why php-fpm8.4 -i is used above instead of php -i.


Next steps

With the server provisioned and the app deployed, move on to:

Hardening the server

Including:

  • Allowing SSH keys only and disabling password authentication.
  • Disabling root login.
  • Changing the SSH port.
  • Tuning Fail2Ban jails.
  • Tuning UFW rules.
  • Applying basic security hardening.

Backups and monitoring

  • Schedule database and file backups.
  • Monitor logs and disk usage.

Conclusion

The real value of this guide is not the commands themselves — it is having a fixed standard. When every server is provisioned the same way, deployment problems become predictable and quick to fix, and projects move between servers without surprises.

Keep these stages as a reference, and bump the version numbers with each new Ubuntu or PHP release.

#VPS #Ubuntu 24.04 #Laravel #Nginx #PHP 8.4 #Composer #Node.js 22 #MySQL #Redis #Supervisor #Certbot #SSL #UFW #Fail2Ban #GitHub Actions #OPcache #automated deployment #server setup #DevOps