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 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:
- Provision the machine with phase 1.
- Follow phase 2 through
.envconfiguration andAPP_KEYgeneration. - Create the database and its user in phase 4.
- Return to phase 2 for the storage link and Nginx, then run migrations at the end of phase 4.
- If the server will host several applications, complete phase 3 before configuring cron and workers. For one simple site, you may stay with the
deployuser and the default PHP-FPM pool. - 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_passsocket 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
OpenSSHbefore 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_passis where you pin the PHP version for this specific site.
Step 10 - Enable the site with a symlink
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
myappand register it as a repository deploy key. The earlier key belongs todeployand 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
777modes are unnecessary. - Resource usage becomes traceable:
htoptells 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 migratein 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
rootauthentication from the Unix socket to a password. Exit MySQL withexit, then usemysql -u root -pinstead ofsudo mysqlfor subsequent logins.
Step 3 - Create the database
CREATE DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Use
utf8mb4, notutf8. MySQL'sutf8stores only three bytes per character, so it cannot hold emoji and some symbols — a common source ofIncorrect string valueerrors when saving user input.utf8mb4is 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
--forceis 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 yourPATH, so a barephpmay 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 themyappuser 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>&1in 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
rereadpicks up new config files.updateactually 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. Usereturn 404instead 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_uriat the end. Without it, a visitor onwww.yourdomain.com/blog/postlands 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://. Runcertbot --nginxagainst both domains so the certificate covers thewwwform 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 sslblocks 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 Fullis 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
certbotcommand. 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 sslblocks with the certificate and key paths.- A
301redirect 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_serverblock 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
700on the directory and600on 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 thegithub-actionsfile, 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 withssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub -E sha256and copy only theSHA256:...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.shmanually 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
restartversusreload: the former kills all workers and starts them again, briefly interrupting in-flight requests.reloadre-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
NOPASSWDon 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 betrue.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 nearsmax_accelerated_files, raise the limit.
Remove the temporary diagnostic route immediately after checking it; exposing OPcache status publicly leaks server details. The FPM
php.inidoes not apply to the CLI, which is whyphp-fpm8.4 -iis used above instead ofphp -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.