This is a beginner guide for a Laravel app where the pages and the API live in the same project. Blade is the frontend. Laravel routes under /api are the backend. There is no separate Node server.
Replace YOUR_SERVER_IP, YOUR_DOMAIN, YOUR_APP, and STRONG_PASSWORD with your own values. Do not copy a real password, APP_KEY, or a private key into a chat, a screenshot, or git.
Introduction
A VPS is a Linux computer in a data center. Visitors type a domain. An A record points that domain at the server's IP. Nginx receives HTTPS and hands every URL to PHP.
One Laravel app answers both kinds of request. They share public/index.php, the same .env, and the same MySQL database.
| Request | Who answers | Why |
|---|---|---|
https://YOUR_DOMAIN/ and /login
|
Laravel routes/web.php
|
Blade pages |
https://YOUR_DOMAIN/api/... |
Laravel routes/api.php
|
The API in the same app |
| MySQL |
127.0.0.1:3306 only |
Laravel's own database. It is not on the public internet |
There is no second port to open. PHP-FPM is a local socket, not a public port. Nginx is the only door.
GitHub Actions cannot upload code until the server already has a folder, a database, .env, Nginx, and an SSH key. Actions logs in as deploy, not as your sudo account. A leaked GitHub key can update the app, but it cannot install packages or change the firewall.
Two accounts, two jobs:
| User | Job |
|---|---|
YOUR_SUDO_USER |
The account you SSH in as. It has sudo. On the Contabo server used for MtandaoBilling this account is sos. A lot of VPS images do not give you a root password. |
deploy |
Owns /var/www/YOUR_DOMAIN, runs Composer and Artisan, and is the GitHub Actions login. It has no password. |
PHP itself runs as www-data. That user must be able to write storage/ and bootstrap/cache even though deploy owns the files.
1. Log in, and do not SSH as deploy yet
From your PC:
ssh YOUR_SUDO_USER@YOUR_SERVER_IP
You are on the server. The prompt looks like YOUR_SUDO_USER@your-server:~$.
Do not run ssh deploy@YOUR_SERVER_IP from that prompt. deploy has no password and, at this point, no SSH key. The server answers Permission denied (publickey). You are already on the machine. Switch user instead:
sudo -u deploy -i
sudo means "run this as another user." -u deploy -i opens a login shell as deploy. Type exit to go back to your sudo account.
apt, Nginx, and MySQL stay on the sudo account. Files inside the app folder are edited as deploy, after that user owns the folder.
2. Install what Laravel needs
You do not need PM2. PHP-FPM already stays running. You do need Node once, on the machine that builds CSS and JavaScript. On the server, Composer installs PHP packages. Vite assets are usually built in GitHub Actions and uploaded, so the server does not have to run npm if that build already happened.
| Package | Why |
|---|---|
nginx |
Receives HTTPS and sends PHP files to PHP-FPM |
php8.3-fpm and PHP extensions |
Runs Blade pages and the /api routes. fpm is the PHP process Nginx talks to |
composer |
Installs Laravel's PHP libraries |
mysql-server |
Laravel's database |
certbot |
Gets a free HTTPS certificate from Let's Encrypt |
acl |
Lets www-data write logs and sessions in a folder owned by deploy
|
ufw |
Firewall. Only SSH, HTTP, and HTTPS should be open |
As YOUR_SUDO_USER:
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx mysql-server certbot python3-certbot-nginx \
git curl unzip ufw acl \
php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring php8.3-xml \
php8.3-curl php8.3-zip php8.3-bcmath php8.3-tokenizer
curl -sS https://getcomposer.org/installer | sudo php -- --install-dir=/usr/local/bin --filename=composer
If php8.3-* is not in your Ubuntu version, install PHP 8.2 or newer and later point Nginx at that socket (ls /run/php/).
Check that the commands exist:
php -v
composer -V
nginx -v
mysql --version
Create deploy once, if it does not exist. --disabled-password is deliberate. GitHub will log in with a key, not a password that can be guessed.
sudo adduser --disabled-password --gecos "" deploy
Open only SSH and the web ports. Do not open MySQL port 3306.
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
OpenSSH keeps your current login alive. Nginx Full is ports 80 and 443.
3. Make the app folder, and make deploy the owner
Nginx will not use this folder as the website root. It uses public/ inside it. The rest of Laravel (.env, app/, vendor/) must stay outside the web root so visitors cannot download it.
sudo mkdir -p /var/www/YOUR_DOMAIN
sudo chown -R deploy:deploy /var/www/YOUR_DOMAIN
chown means "this user owns these files."
A folder created earlier as root stays owned by root. mkdir -p as deploy does not change a folder that already exists, and nano then fails with Permission denied. Check with:
ls -ld /var/www/YOUR_DOMAIN
The owner must be deploy deploy. If it says root root:
sudo chown -R deploy:deploy /var/www/YOUR_DOMAIN
4. Create the MySQL database
The Linux user deploy is not the database user. Create a separate MySQL user. In MySQL, localhost and 127.0.0.1 are two different accounts.
localhost means "connect through a Unix socket." 127.0.0.1 means "connect with TCP." If .env says DB_HOST=127.0.0.1, a user created only @localhost is invisible. ALTER USER for the TCP account then fails with ERROR 1396 because that account does not exist yet.
PHP's MySQL driver can use MySQL 8's normal caching_sha2_password plugin. You do not need mysql_native_password unless this same database is also used by Prisma. This guide uses the normal plugin.
As YOUR_SUDO_USER:
sudo mysql
sudo mysql works without a MySQL root password because Ubuntu lets the system admin in through the socket. Invent a long password. Call it STRONG_PASSWORD here. Use the same string later in .env. Do not reuse a password from a chat log.
CREATE DATABASE your_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'your_app_user'@'127.0.0.1' IDENTIFIED BY 'STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON your_app.* TO 'your_app_user'@'127.0.0.1';
FLUSH PRIVILEGES;
SELECT user, host FROM mysql.user WHERE user = 'your_app_user';
The host column must include 127.0.0.1. Then type exit.
5. Write .env on the server only
There is one .env, at the Laravel project root, not a separate frontend file. GitHub must not upload it.
As deploy:
nano /var/www/YOUR_DOMAIN/.env
Start from your project's .env.example and set at least these. Leave APP_KEY empty. Artisan fills it after Composer has run.
APP_NAME=YOUR_APP
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://YOUR_DOMAIN
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_app
DB_USERNAME=your_app_user
DB_PASSWORD=STRONG_PASSWORD
SESSION_DRIVER=database
CACHE_STORE=database
QUEUE_CONNECTION=database
APP_DEBUG=false so a crash does not print secrets in the browser. The real error still goes to storage/logs/laravel.log.
SESSION_DRIVER=database is fine for a Laravel app that owns MySQL, but only after php artisan migrate has created the sessions table. If you want the first page to work before that migrate, set SESSION_DRIVER=file and CACHE_STORE=file instead. A 500 that says no such table: sessions means the driver is database and migrate has not run.
As the sudo user, lock the file:
sudo chown deploy:deploy /var/www/YOUR_DOMAIN/.env
sudo chmod 600 /var/www/YOUR_DOMAIN/.env
600 means owner read/write, everyone else nothing.
6. Install Nginx and the free certificate
The website root is public/, not the project root. If you point Nginx at /var/www/YOUR_DOMAIN, visitors can request .env.
As YOUR_SUDO_USER:
sudo nano /etc/nginx/sites-available/YOUR_DOMAIN
Paste this. Read the comments. They are the reason each line exists.
server {
listen 80;
listen [::]:80;
server_name YOUR_DOMAIN;
# Only public/ is on the internet. app/, .env, and vendor/ are not.
root /var/www/YOUR_DOMAIN/public;
index index.php;
access_log /var/log/nginx/YOUR_APP.access.log;
error_log /var/log/nginx/YOUR_APP.error.log;
charset utf-8;
client_max_body_size 20m;
# Real files (CSS, images, Vite /build) are sent as files.
# Every other URL, including /api/..., goes to public/index.php.
# Laravel then chooses web.php or api.php. Nginx does not split them.
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
# PHP-FPM socket. If this file does not exist, run: ls /run/php/
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param HTTPS $https if_not_empty;
}
# Block .env and .git if they ever land inside public/.
# /.well-known/ stays open so Certbot can prove you own the domain.
location ~ /\.(?!well-known).* {
deny all;
}
}
Enable the site and test it before you reload. nginx -t stops a typo from taking every site on the server down.
sudo ln -s /etc/nginx/sites-available/YOUR_DOMAIN /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
A 404 or a PHP error now is normal. The app is not uploaded yet.
Then ask Let's Encrypt for a certificate. Certbot is free. The prompt "agree to register with the ACME server" is their terms, not a paid account. Type yes, and enter an email so they can warn you before a certificate expires.
sudo certbot --nginx -d YOUR_DOMAIN
Certbot edits the Nginx file on the server. It adds port 443 and redirects HTTP to HTTPS. Do not copy those certificate paths into git.
7. Give GitHub a key that can log in as deploy
A password in GitHub is worse than a key you can delete. The key has two halves:
| File | Where it goes |
|---|---|
YOUR_APP_deploy.pub |
The server. This is public. It says "this key is allowed." |
YOUR_APP_deploy |
Your PC, then the GitHub secret. This is private. Never commit it, and never paste it into a chat. |
On your PC, not inside the SSH session:
ssh-keygen -t ed25519 -C "github-actions-YOUR_APP" -f ~/.ssh/YOUR_APP_deploy -N ""
scp ~/.ssh/YOUR_APP_deploy.pub YOUR_SUDO_USER@YOUR_SERVER_IP:~/
In PowerShell, if ~ is not your user folder, use the full path, for example C:\Users\ADMIN\.ssh\YOUR_APP_deploy.pub.
On the VPS, as YOUR_SUDO_USER:
sudo mkdir -p /home/deploy/.ssh
sudo tee -a /home/deploy/.ssh/authorized_keys < ~/YOUR_APP_deploy.pub
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
authorized_keys is the list of public keys allowed to log in as that user. 700 and 600 are not optional. SSH ignores a key file if other users can read it.
Test from your PC. You should land in /home/deploy with no password:
ssh -i ~/.ssh/YOUR_APP_deploy deploy@YOUR_SERVER_IP
8. Add the GitHub secrets
In the repository: Settings, then Secrets and variables, then Actions, then New repository secret.
| Secret | Value |
|---|---|
SSH_HOST |
YOUR_SERVER_IP |
SSH_USERNAME |
deploy |
SSH_PRIVATE_KEY |
The whole private file, including the BEGIN and END lines |
SSH_PORT |
22 |
DEPLOY_PATH |
/var/www/YOUR_DOMAIN |
Show the private key on your PC only, then paste it into GitHub. Use the file without .pub.
cat ~/.ssh/YOUR_APP_deploy
A small workflow you can put in .github/workflows/deploy.yml of that Laravel project. It builds the Vite files in GitHub, uploads the app, and does not upload .env or node_modules.
name: Deploy Laravel
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
tools: composer:v2
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: PHP dependencies and Vite build
run: |
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
npm ci
npm run build
- name: Upload
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT || 22 }}
source: ".,!node_modules,!.env"
target: ${{ secrets.DEPLOY_PATH }}
overwrite: true
- name: Migrate and refresh caches
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT || 22 }}
script: |
cd "${{ secrets.DEPLOY_PATH }}"
composer install --no-dev --optimize-autoloader --no-interaction
php artisan storage:link || true
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
sudo systemctl reload php8.3-fpm
deploy cannot run sudo systemctl reload unless you allow that one command. If the reload line fails, SSH in as YOUR_SUDO_USER and run sudo systemctl reload php8.3-fpm yourself after the first deploy. PHP keeps the old cached config until that reload.
public/build must be in the upload. If GitHub skips npm run build, Laravel pages that use @vite return 500 with "Vite manifest not found."
9. First boot on the server
After the first upload, as deploy:
cd /var/www/YOUR_DOMAIN
composer install --no-dev --optimize-autoloader --no-interaction
php artisan key:generate --force
php artisan migrate --force
php artisan storage:link
php artisan config:cache
php artisan route:cache
php artisan view:cache
key:generate writes APP_KEY. Without it, every page is 500. Do not run it again on later deploys. A new key logs everybody out and can make old encrypted cookies unreadable.
migrate --force creates tables, including sessions if you use the database session driver. --force is required because Laravel refuses to migrate when APP_ENV=production unless you say so.
storage:link makes public/storage point at uploaded files. Skip the error if the link already exists.
config:cache bakes .env into one PHP file. After this, editing .env does nothing until you run config:cache again.
PHP must be able to write logs and sessions. As YOUR_SUDO_USER:
sudo mkdir -p /var/www/YOUR_DOMAIN/storage/framework/{cache/data,sessions,views} \
/var/www/YOUR_DOMAIN/storage/logs \
/var/www/YOUR_DOMAIN/bootstrap/cache
sudo chown -R deploy:www-data /var/www/YOUR_DOMAIN/storage /var/www/YOUR_DOMAIN/bootstrap/cache
sudo chmod -R ug+rwX /var/www/YOUR_DOMAIN/storage /var/www/YOUR_DOMAIN/bootstrap/cache
sudo systemctl reload php8.3-fpm
www-data is the PHP user. The group write bit is what lets PHP save sessions and log a 500 instead of dying with Permission denied.
10. Confirm the site
Open https://YOUR_DOMAIN/ and one API URL, for example https://YOUR_DOMAIN/api/user or whatever your routes/api.php exposes. A 401 on a protected API route is fine. It means Laravel answered. A 404 from Nginx, with no Laravel styling, means the request never reached index.php.
If the page is 500:
sudo grep "production.ERROR" /var/www/YOUR_DOMAIN/storage/logs/laravel.log | tail -1
The first production.ERROR line is the cause. The long stack under it is not.
| What the log says | What to do |
|---|---|
No application encryption key |
php artisan key:generate --force, then config:cache, then reload PHP-FPM |
no such table: sessions |
php artisan migrate --force, or set SESSION_DRIVER=file and cache config again |
Permission denied under storage/
|
Repeat the chown and chmod in step 9 |
Vite manifest not found |
npm run build in GitHub (or on the server) so public/build/manifest.json exists |
Access denied for user |
DB_USERNAME, DB_PASSWORD, and DB_HOST=127.0.0.1 must match the MySQL user from step 4 |
After any .env change:
cd /var/www/YOUR_DOMAIN
php artisan config:cache
Then as the sudo user: sudo systemctl reload php8.3-fpm.
Conclusion
Prepare the server before GitHub deploys. The order is: PHP, Composer, Nginx, and MySQL, a deploy user, a folder that user owns, a MySQL user at 127.0.0.1, one .env, Certbot, an SSH key, then a push.
Blade pages and /api are the same app. Nginx sends both to public/index.php. You do not run a second process and you do not open an API port.
When it fails, look here first:
-
storage/logs/laravel.logfor a 500 page. Read theproduction.ERRORline. - Confirm Nginx
rootis.../public, not the project root. - Confirm
DB_HOSTis127.0.0.1and thatSELECT user, host FROM mysql.usershows that host. - After an
.envedit,php artisan config:cacheand reload PHP-FPM.
Do not put the database password, APP_KEY, or the private key in git. deploy is the only account GitHub should log in as.
Top comments (0)