How to Deploy a Laravel App on a DigitalOcean VPS From Scratch
🔍

No results found. Try a different search term.

📝 Placeholder Values — Replace These With Your Own

Throughout this document, values wrapped in <angle-brackets> are placeholders. They highlight what you need to customize — there is no single correct value, you decide based on your project.

<your-username> your Linux server username, e.g. john
<yourdomain.com> your actual domain, e.g. myapp.com
<your-app> your app/folder name, e.g. myapp
<your_database> your MySQL database name, e.g. myapp_db
<YOUR_SERVER_IP> your DigitalOcean droplet IP address
<YourName> your Windows username, e.g. John
id_github your SSH key filename — name it anything descriptive
prod-server your SSH alias — choose any name you like
<YourAppName> your Laravel app name, e.g. MyApp
<your_db_password> your MySQL user password — make it strong
<your_resend_api_key> API key from resend.com dashboard
🌊

Create a DigitalOcean Droplet

ℹ️
What is a Droplet?

A Droplet is DigitalOcean's term for a Virtual Private Server (VPS) — a cloud computer that runs 24/7. It's your server on the internet. Unlike shared hosting, you have full root access and can install anything you want on it.

Step-by-Step Droplet Creation

1
Go to digitalocean.com → Dashboard → Create → Droplet

Log in to digitalocean.com. At the top of the dashboard click the green Create button, then select Droplets from the dropdown.

2
Choose Region closest to you

Pick the data center geographically closest to your users for the lowest latency. For US-based users, New York or San Francisco are common choices.

3
Choose OS → Ubuntu → Latest recommended version

Ubuntu LTS (Long Term Support) is the most popular and best-supported Linux distro for web servers. Always pick the latest LTS version.

4
Droplet Type → Shared CPU (Basic)

For most Laravel apps, the Basic plan is sufficient to start. You can always upgrade later without losing data.

5
CPU Options → Choose between Regular, Premium AMD, or Premium Intel

Regular (SSD) is the cheapest option and fine for small or personal projects. Premium AMD and Premium Intel (both NVMe SSD) offer better performance. Note that not all CPU types are available in every region — if your preferred type is unavailable, switch to another region or pick the next best option. Pricing varies by region too.

6
Authentication Method → Password

Choose Password for initial setup — we will disable root password login and switch to SSH key authentication after the server is configured.

7
Finalize Details → Hostname → prod-server (or any name)

The hostname is just a label for your droplet — it doesn't affect your domain. Use something descriptive like prod-server or prod-server.

8
Click "Create Droplet"

DigitalOcean will provision your server in about 30–60 seconds and assign it a public IP address. Save this IP — you'll use it for SSH, DNS, and everything else.

Save your IP address!

Once the droplet is created, copy the IP address from the dashboard. You'll need it to SSH in, set up DNS records, and configure your domain.

Recommended Specs for Laravel App

SettingRecommendedNotes
OSUbuntu 24.04 LTSLatest stable, 5-year support
PlanBasic — Premium AMD or Premium IntelNVMe SSD, better performance than Regular
Size2GB RAM / 1 vCPU~$14–$16/mo depending on region & CPU type. Good starting point for Laravel
RegionClosest to your usersNYC, SFO, LON, FRA, SGP etc. — note: not all CPU types available in all regions
Auth MethodPassword (initially)Switch to SSH keys after setup

Connect to Your New Droplet

CMD / PowerShell
ssh root@<YOUR_SERVER_IP>

# First time: type "yes" to accept the fingerprint
# Then enter your root password
⚠️
After logging in — first things first

Don't install anything yet. First update all packages (apt update && apt upgrade -y), then create a sudo user and disable root login for security.


🗺️

How Everything Fits Together

When a user visits your website, here's what happens behind the scenes and why each piece is needed:

ToolRoleAnalogy
DigitalOceanProvides the physical server (VPS) running 24/7 in the cloudThe building your office is in
Ubuntu (OS)The operating system the server runs onThe building's power and plumbing
NginxWeb server — receives HTTP/HTTPS requests and routes them to PHPThe receptionist at the front desk
PHP 8.3 + FPMExecutes your Laravel application codeThe staff doing the actual work
MySQLStores all your application data (users, records, etc.)The filing cabinet
ComposerInstalls and manages PHP packages (Laravel, etc.)The supply department
Node.js + npmCompiles CSS/JS frontend assets at build timeThe printing press — used once, then shelved
Certbot / SSLEncrypts all traffic between browser and server (HTTPS)The locks on the doors
Git / GitHubVersion control — deploy new code via git pullThe delivery service for code updates
SSH KeysSecure, password-free authentication to your serverA key card instead of a password at the door
ResendSends transactional emails reliably via HTTP APIThe postal service
Request flow in plain English

Browser → Nginx (port 80/443) → PHP-FPM → Laravel → MySQL → Response back through Nginx → Browser


🔐

SSH Setup

Connect to your DigitalOcean droplet via SSH from Windows CMD or PowerShell.

Basic Connection

💻 LocalCMD / PowerShell
ssh root@<YOUR_SERVER_IP>
⚠️
Permission Denied?

If you selected an SSH key during droplet creation, password auth may be disabled. Use the DigitalOcean Web Console instead.

Reset Root Password (DigitalOcean Dashboard)

1
Go to your Droplet in DO dashboard

Click "Access" in the left sidebar

2
Click "Reset Root Password"

DigitalOcean emails you a temporary password

3
Open Web Console

Log in with root + new password. You'll be prompted to change it on first login

Restart SSH Service

🖥 ServerUbuntu
sudo systemctl restart ssh
ℹ️
Note

On Ubuntu the SSH service is named ssh not sshd.

Edit SSH Config

🖥 Server
sudo nano /etc/ssh/sshd_config

# Set these values:
PasswordAuthentication yes
PermitRootLogin yes

👤

Create Sudo User & Disable Root

ℹ️
Why not just use root?

The root user has unrestricted access to everything on the server — one wrong command can destroy the entire system with no undo. A sudo user can still run privileged commands when needed (by typing sudo), but every other action is sandboxed. Additionally, attackers always try the username root first in brute-force attacks, so disabling root login adds a meaningful layer of protection.

For security, create a regular user with sudo privileges and disable root login. Never run a production server as root permanently.

ℹ️
Why disable root?

The root user has unlimited power — one wrong command can destroy the entire system. Creating a sudo user means you still have admin access when needed (via sudo), but accidental commands or an attacker who gets in can't cause as much damage. Most automated attacks also specifically target the root account.

Add New User

🖥 Serverrun as root
adduser user <your-username>     # Creates user and sets password

Add User to Sudo Group

🖥 Server
usermod -aG sudo sudo <your-username>     # Grants sudo privileges

Switch to New User

🖥 Server
su - <your-username>          # Switch to new user
exit                 # Log out from user session
exit                 # Log out from server

Login with New User

💻 LocalPowerShell / CMD
ssh <your-username>@<YOUR_SERVER_IP>

Disable Root Login (after SSH key is set up)

🖥 Server/etc/ssh/sshd_config
sudo nano /etc/ssh/sshd_config

# Update these values:
PermitRootLogin no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication no
PermitEmptyPasswords no
🚨
Do this ONLY after SSH key is working

Make sure you can log in with your SSH key as the new user BEFORE disabling password auth. Otherwise you'll be locked out.

🖥 Serverrestart SSH
sudo systemctl restart ssh

Configure SSH Access (authorized_keys setup)

🖥 Serveras root
sudo -u <your-username> mkdir -p /home/<your-username>/.ssh
sudo -u <your-username> chmod 700 /home/<your-username>/.ssh
sudo chown -R <your-username>:<your-username> /home/<your-username>/.ssh
sudo -u <your-username> touch /home/<your-username>/.ssh/authorized_keys
sudo -u <your-username> chmod 600 /home/<your-username>/.ssh/authorized_keys
sudo chmod 755 /home/<your-username>
sudo chown <your-username>:<your-username> /home/<your-username>

🗝️

SSH Keys

ℹ️
Why use SSH keys instead of passwords?

Passwords can be guessed, brute-forced, or stolen. SSH keys work with a mathematically linked pair — a private key (stays on your machine, never shared) and a public key (placed on the server). The server verifies your identity by checking if your private key matches the public key — without ever transmitting the private key over the network. This makes SSH key auth virtually impossible to brute-force.

Generate SSH key pairs and copy them to your server for password-less authentication.

ℹ️
Why SSH Keys instead of passwords?

Passwords can be brute-forced. SSH keys use asymmetric cryptography — a private key stays only on your machine, and a public key goes on the server. Even if someone intercepts traffic, they can't log in without your private key file.

Generate SSH Key (Windows PowerShell)

Run this on your local Windows machine — not the server. It creates two files: a private key (keep secret) and a .pub public key (safe to share).

💻 PowerShellkeygen
ssh-keygen -t ed25519 -C "your_email@example.com"
AlgorithmCommandNotes
Ed25519-t ed25519✅ Recommended — fastest, most secure
ECDSA 521-t ecdsa -b 521✅ Very strong, NIST curve
RSA 4096-t rsa -b 4096⚠️ Older, slower

Copy Public Key to Server (Windows)

💻 Local CMD
type "%USERPROFILE%\.ssh\id_ed25519.pub" | ssh root@<YOUR_SERVER_IP> "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
⚠️
PowerShell vs CMD

The type command works in CMD. In PowerShell use Get-Content or run CMD first.

Fix SSH Key Permissions on Server

🖥 Server
chmod 600 ~/.ssh/id_github

Add Key to SSH Agent (Windows PowerShell Admin)

💻 PowerShellRun as Administrator
Set-Service ssh-agent -StartupType Automatic
Start-Service ssh-agent
ssh-add "$env:USERPROFILE\.ssh\id_ed25519"

Test GitHub SSH Connection

💻 LocalPowerShell / CMD
ssh -T git@github.com
# Expected: Hi username! You've successfully authenticated...

Copy SSH Key from Local to Server (SCP)

💻 PowerShell
scp "C:\Users\<YourName>\.ssh\id_github" <your-username>@<YOUR_SERVER_IP>:~/.ssh/
scp "C:\Users\<YourName>\.ssh\id_github.pub" <your-username>@<YOUR_SERVER_IP>:~/.ssh/

Load SSH Key on Server

🖥 Server
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_github

⚙️

SSH Config Shortcut

ℹ️
What is the SSH config file?

The SSH config file (~/.ssh/config) lets you define shortcuts and settings for SSH connections. Instead of typing ssh -i "D:\~ssh\id_github" <your-username>@<YOUR_SERVER_IP> every time, you define it once in the config and then just type ssh prod-server. SSH reads this file automatically — no flags needed.

ℹ️
This file lives on your LOCAL machine (Windows)

The local SSH config tells your Windows machine how to connect to servers. There is also a separate SSH config on the server itself for connecting to GitHub — see the Persistent GitHub SSH section.

Create Config File (PowerShell)

PowerShell
New-Item "$env:USERPROFILE\.ssh\config" -ItemType File
notepad "$env:USERPROFILE\.ssh\config"

Config File Contents

~/.ssh/config (Local Windows)
Host prod-server
    HostName <YOUR_SERVER_IP>_ADDRESS<YOUR_SERVER_IP>
    User <your-username>
    IdentityFile C:\Users\<YourName>\.ssh\id_github

What Each Field Means

FieldWhat it doesExample
HostThe alias/shortcut name you type in the terminal — can be anything you wantprod-server
HostNameThe actual IP address or domain of the server — must be IP only, no username143.244.152.100
UserThe username to log in as on the server<your-username>
IdentityFilePath to your private key file — tells SSH which key to use for this serverC:\Users\<YourName>\.ssh\id_github
⚠️
Common mistakes

HostName must be the raw IP only — never <your-username>@IP. The User field handles the username separately. Also make sure the config file has NO .txt extension — just config with nothing after it.

Connect Using Alias

PowerShell / CMD
ssh prod-server     # Instead of: ssh -i "path/to/key" <your-username>@<YOUR_SERVER_IP>

Move SSH Files to Default Location

PowerShell
# Move files from custom folder to default .ssh location
Move-Item "C:\Users\<YourName>\.ssh\~ssh\*" "C:\Users\<YourName>\.ssh\"
Remove-Item "C:\Users\<YourName>\.ssh\~ssh"

🔄

Server Updates

ℹ️
Why update right away?

A freshly created DigitalOcean droplet may already be days or weeks behind on security patches. Unpatched servers are a common attack vector — bots scan the internet constantly for known vulnerabilities. Updating immediately closes those gaps before you expose the server to the internet.

Always update your server packages after initial setup, especially security patches.

🖥 Server
# Update package list and upgrade all packages
apt update && apt upgrade -y

# Remove unused packages
apt autoremove -y

# Check if reboot is needed (kernel updates)
cat /var/run/reboot-required

# Reboot if file exists
reboot
During upgrade prompts

For "Which services should be restarted?" press Enter to accept defaults. For GRUB config, keep the local version.


🐘

PHP 8.3

Install PHP 8.3 with all extensions needed for a Laravel application.

Why Use the Ondrej Repository?

Ubuntu's built-in package manager ships with older PHP versions — for example, Ubuntu 24 comes with PHP 8.1 by default. Ondrej Surý is a Debian/Ubuntu maintainer who maintains an unofficial but widely trusted PPA (Personal Package Archive) that provides the latest PHP versions (8.2, 8.3, 8.4). Without this PPA, you'd be stuck with an outdated PHP version that may not support the latest Laravel features.

Add Ondrej PHP Repository

🖥 Server
sudo add-apt-repository ppa:ondrej/php
sudo apt update

Install PHP + All Laravel Extensions

🖥 Server
sudo apt install -y php8.3-cli php8.3-fpm php8.3-mysql php8.3-sqlite3 \
  php8.3-curl php8.3-mbstring php8.3-xml php8.3-zip php8.3-gd \
  php8.3-intl php8.3-bcmath php8.3-opcache
ExtensionPurpose
php8.3-cliRun PHP from command line (artisan)
php8.3-fpmPHP FastCGI for Nginx
php8.3-mysqlMySQL database connection
php8.3-sqlite3SQLite support
php8.3-curlHTTP requests
php8.3-mbstringString handling — required by Laravel
php8.3-xmlXML + DOM parsing
php8.3-zipZip file handling
php8.3-gdImage processing
php8.3-intlInternationalization
php8.3-bcmathPrecise math calculations
php8.3-opcachePHP performance caching (production)
php8.3-commonIncludes tokenizer, fileinfo, ctype (auto-installed)

Verify Installation

🖥 Server
php -v          # Check PHP version
php -m          # List all loaded extensions

Install unzip (important for Composer)

🖥 Server
sudo apt install -y unzip

Configure PHP-FPM Upload Limits

⚠️
php -i shows CLI values, not FPM values

Running php -i on the server reads the CLI php.ini (/etc/php/8.3/cli/php.ini), not the PHP-FPM config used by Nginx. Changes to the CLI ini have no effect on web requests. Always edit /etc/php/8.3/fpm/php.ini for production web uploads.

By default, PHP-FPM limits file uploads to 2 MB. If you have a media uploader or allow users to upload images/files, increase these limits or you will get a 413 Request Entity Too Large error.

🖥 Server
sudo nano /etc/php/8.3/fpm/php.ini

Find and update these four values (use Ctrl+W to search inside nano):

📄 Config/etc/php/8.3/fpm/php.ini
upload_max_filesize = 20M    # Max size of a single uploaded file (default: 2M)
post_max_size = 25M          # Max size of entire POST body — must be > upload_max_filesize
max_execution_time = 60      # Max seconds a script can run (default: 30)
max_input_time = 60          # Max seconds to parse input (default: 60, raise for slow uploads)

Then restart PHP-FPM to apply the changes:

🖥 Server
sudo systemctl restart php8.3-fpm
ℹ️
Verify the correct file was updated

After editing, confirm your values were saved — php -i will still show old values because it reads the CLI ini. Grep the FPM file directly instead:

grep -n "upload_max_filesize\|post_max_size" /etc/php/8.3/fpm/php.ini

📦

Composer

Install Composer (PHP package manager) — required for Laravel.

ℹ️
What is Composer?

Composer is PHP's dependency manager — similar to npm for Node.js. Laravel and all its packages are installed and managed through Composer. When you run composer install, it reads composer.json and downloads all required packages into the vendor/ folder.

🖥 Serverinstall composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
composer --version
⚠️
Don't use apt install composer

Using sudo apt install composer installs an older version. Always install from getcomposer.org for the latest stable release.

Composer Security Audit

🖥 Server
composer audit           # Check for vulnerabilities
composer update          # Update all packages to fix vulnerabilities

🗄️

MySQL

ℹ️
What does mysql_secure_installation do?

It runs a security hardening script that: sets a root password, removes anonymous users (who can log in without credentials), disables remote root login, and removes the test database. Always run this on a production server right after installation.

Install MySQL

🖥 Server
sudo apt install -y mysql-server
sudo mysql_secure_installation   # Run security setup — say Y to all
sudo systemctl status mysql

Create Database & User for Laravel

🗄 MySQL
sudo mysql

CREATE DATABASE <your_database>;
CREATE USER '<your-username>'@'localhost' IDENTIFIED BY 'yourpassword';
GRANT ALL PRIVILEGES ON <your_database>.* TO '<your-username>'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Common MySQL Commands

🗄 MySQL
# Check privileges
SHOW GRANTS FOR '<your-username>'@'localhost';

# Revoke privileges
REVOKE ALL PRIVILEGES ON <your_database>.* FROM '<your-username>'@'localhost';
FLUSH PRIVILEGES;

# Reset user password
ALTER USER '<your-username>'@'localhost' IDENTIFIED BY 'newpassword';
FLUSH PRIVILEGES;

# Drop and recreate user
DROP USER '<your-username>'@'localhost';
CREATE USER '<your-username>'@'localhost' IDENTIFIED BY 'newpassword';
GRANT ALL PRIVILEGES ON <your_database>.* TO '<your-username>'@'localhost';
FLUSH PRIVILEGES;
ℹ️
localhost vs 127.0.0.1

In MySQL these are different. localhost uses Unix socket, 127.0.0.1 uses TCP. If your .env uses DB_HOST=127.0.0.1, create user with '<your-username>'@'127.0.0.1' too.

⚠️
Special characters in password

Always wrap passwords in double quotes in .env: DB_PASSWORD="your@pass#word"

Test Connection

🖥 Server
mysql -u <your-username> -p -h 127.0.0.1 <your_database>

Allow Remote Connections (for MySQLYog etc.)

🖥 Server
# Edit MySQL config
sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
# Change: bind-address = 0.0.0.0

# Create remote user
sudo mysql
CREATE USER '<your-username>'@'%' IDENTIFIED BY 'yourpassword';
GRANT ALL PRIVILEGES ON <your_database>.* TO '<your-username>'@'%';
FLUSH PRIVILEGES;

sudo systemctl restart mysql

🌐

Nginx

ℹ️
What is Nginx?

Nginx (pronounced "engine-x") is a high-performance web server. It listens for incoming HTTP/HTTPS requests and forwards them to PHP-FPM which runs your Laravel code. Think of Nginx as the front door, and PHP-FPM as the engine behind it. Nginx is preferred over Apache for Laravel because it uses less memory and handles more concurrent connections.

Install Nginx

🖥 Server
sudo apt install -y nginx
sudo systemctl status nginx

Nginx Service Commands

🖥 Server
sudo nginx -t                    # Test config for errors
sudo systemctl restart nginx    # Restart Nginx
sudo systemctl reload nginx     # Reload without downtime
sudo systemctl status nginx     # Check status

🟩

Node.js

ℹ️
Why do we need Node.js for a PHP app?

Laravel uses Vite (a Node.js-based tool) to compile and bundle your frontend assets — CSS, JavaScript, Tailwind, Vue/React components — into optimized files for production. Node.js itself doesn't run on the server in production; it's only used at build time. Without it, npm run build won't work and your site may show unstyled pages.

Install Node.js 22 LTS for compiling Laravel frontend assets (Vite).

🖥 Serverinstall node.js 22
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs

node -v    # Verify Node version
npm -v     # Verify NPM version
ℹ️
NVM vs Direct Install

For a production server with one or two Laravel apps, direct install is simpler. NVM (Node Version Manager) is only needed if you run multiple apps requiring different Node versions.


🐙

GitHub Deploy

ℹ️
Why clone into /var/www/?

/var/www/ is the standard Linux directory for web application files. Nginx is configured to serve files from here by default. Each app gets its own subdirectory, e.g. /var/www/<your-app>/. Never clone into your home directory (~) for a production app — Nginx won't have access to it.

Clone Repository to Server

🖥 Server
# Create and own the target directory
sudo mkdir -p /var/www/<your-app>
sudo chown <your-username>:<your-username> /var/www/<your-app>

# Clone directly into folder (note the dot at the end)
cd /var/www/<your-app>
git clone git@github.com:your/repo.git .
⚠️
SSH Key needed on server for GitHub

Copy your GitHub SSH key to the server and load it via ssh-agent before cloning. See SSH Keys section.

Fix Double Nested Folder

Server — if cloned into <your-app>/<your-app>
mv /var/www/<your-app>/<your-app>/* /var/www/<your-app>/
mv /var/www/<your-app>/<your-app>/.* /var/www/<your-app>/ 2>/dev/null
rmdir /var/www/<your-app>/<your-app>

🚀

Laravel Setup

Install Dependencies

🖥 Server
cd /var/www/<your-app>
composer install

Create .env File

🖥 Server
# If .env.example exists:
cp .env.example .env

# If not (manually create):
nano .env

Minimal .env for Production

📄 Config.env
APP_NAME=<YourAppName>
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://<yourdomain.com>

LOG_CHANNEL=stack

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=<your_database>
DB_USERNAME=<your-username>
DB_PASSWORD="<your_db_password>"

CACHE_STORE=file
SESSION_DRIVER=file
QUEUE_DRIVER=sync

MAIL_MAILER=resend
MAIL_FROM_ADDRESS="no-reply@mail.<yourdomain.com>"
MAIL_FROM_NAME="${APP_NAME}"
RESEND_API_KEY=<your_resend_api_key>

Generate App Key & Migrate

🖥 Server
php artisan key:generate
php artisan migrate

Clear All Caches

🖥 Server
# Clear everything at once
php artisan optimize:clear

# Or individually:
php artisan config:clear
php artisan cache:clear
php artisan view:clear
php artisan route:clear

# Remove cached config file manually
rm /var/www/<your-app>/bootstrap/cache/config.php

Create Admin User via Tinker

🖥 Server
php artisan tinker

# Create user
App\Models\User::create([
    'name' => '<Your Name>',
    'email' => 'you@email.com',
    'password' => bcrypt('<your_password>')
]);

# Update user role
App\Models\User::where('email', 'you@email.com')
    ->update(['role' => 'admin', 'is_admin' => 1]);

exit

📄

Nginx Server Block Config

ℹ️
What is a Server Block?

A Server Block (Nginx's equivalent of Apache's Virtual Host) tells Nginx: "When a request comes in for this domain, serve files from this folder." Without it, Nginx doesn't know your Laravel app exists. You can have multiple server blocks to host multiple websites on the same server, each with its own domain and root directory.

ℹ️
sites-available vs sites-enabled

sites-available is where you store all your config files. sites-enabled is where Nginx looks for active sites. You create a symbolic link (shortcut) from sites-enabled → sites-available, so the same file is used without duplicating it. This lets you easily enable or disable sites by adding/removing the link.

Create Config File

🖥 Server
sudo nano /etc/nginx/sites-available/<your-app>.conf

Laravel Server Block

📄 Config/etc/nginx/sites-available/<your-app>.conf
server {
    listen 80;
    server_name <your-app>.<yourdomain.com> www.<your-app>.<yourdomain.com>;
    root /var/www/<your-app>/public;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    index index.php;
    charset utf-8;

    client_max_body_size 20M;    # Allow uploads up to 20 MB (default is 1 MB — causes 413 errors)

    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;

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

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
⚠️
413 Request Entity Too Large — two limits to check

If file uploads fail with a 413 error, there are two independent limits that both need to be raised:

  • Nginxclient_max_body_size inside the server {} block (default: 1 MB). Add the line above and restart Nginx.
  • PHP-FPMupload_max_filesize and post_max_size in /etc/php/8.3/fpm/php.ini (default: 2 MB). See the PHP section above.

Nginx rejects the request before PHP even sees it when the body exceeds client_max_body_size, so fix Nginx first, then PHP-FPM.

Enable Site (Symbolic Link)

🖥 Server
sudo ln -s /etc/nginx/sites-available/<your-app>.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx

Remove Default Nginx Config

🖥 Serverfix nginx default
sudo rm /etc/nginx/sites-available/default
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl restart nginx

DNS Records (DigitalOcean)

TypeHostnameValue
A@<YOUR_SERVER_IP>
Awww<YOUR_SERVER_IP>
Amail<YOUR_SERVER_IP>

🔒

SSL Certificate (Let's Encrypt)

ℹ️
Why do we need SSL?

Without SSL, your site runs on http:// — meaning all data sent between the browser and your server (passwords, form data, session tokens) is transmitted in plain text and can be intercepted. SSL encrypts all traffic, enables https://, shows the padlock icon in browsers, and is required by Google for good SEO rankings. Browsers also show "Not Secure" warnings for non-HTTPS sites which kills user trust. Certbot automates the entire process for free using Let's Encrypt.

Install a free SSL certificate using Certbot to enable HTTPS.

ℹ️
What is Let's Encrypt / Certbot?

Let's Encrypt is a free, automated Certificate Authority that issues SSL certificates. Certbot is the tool that talks to Let's Encrypt, proves you own the domain, gets the certificate, installs it into your Nginx config automatically, and sets up auto-renewal every 90 days — all for free.

Install Certbot

🖥 Server
sudo apt install -y certbot python3-certbot-nginx

Get Certificate

🖥 Server
sudo certbot --nginx -d <your-app>.<yourdomain.com> -d www.<your-app>.<yourdomain.com>

Verify Auto-renewal

🖥 Server
sudo certbot renew --dry-run
⚠️
www DNS must exist before running Certbot

If you get NXDOMAIN error for www.<yourdomain.com>, add the A record for www in DigitalOcean DNS first, wait a few minutes, then retry.

Certbot Auto-adds to Nginx Config

After running, your Nginx config will have these blocks added automatically:

📄 Configauto-generated by certbot
server {
    if ($host = <your-app>.<yourdomain.com>) {
        return 301 https://$host$request_uri;
    }
    if ($host = www.<your-app>.<yourdomain.com>) {
        return 301 https://$host$request_uri;
    }
    listen 80;
    server_name <your-app>.<yourdomain.com> www.<your-app>.<yourdomain.com>;
    return 404;
}

🌍

Domain & DNS (Namecheap → DigitalOcean)

ℹ️
Why do we need to connect them?

You bought your domain on Namecheap, but your server is on DigitalOcean. By default they don't know about each other. You need to tell Namecheap: "Let DigitalOcean manage the DNS for this domain". DNS is what translates <yourdomain.com> into your server's IP address so browsers can find it.

Part 1 — Add Domain to DigitalOcean

1
Go to DigitalOcean → Networking → Domains

Click Add Domain, enter <yourdomain.com> and click Add Domain.

2
Note DigitalOcean's nameservers

DigitalOcean uses these three nameservers — you'll need them for Namecheap:

DigitalOcean Nameservers
ns1.digitalocean.com
ns2.digitalocean.com
ns3.digitalocean.com

Part 2 — Point Namecheap to DigitalOcean

1
Log in to Namecheap → Domain List

Find your domain and click Manage.

2
Go to Nameservers section

Change the dropdown from "Namecheap BasicDNS" to "Custom DNS".

3
Enter DigitalOcean's nameservers

Add all three: ns1.digitalocean.com, ns2.digitalocean.com, ns3.digitalocean.com and click the checkmark to save.

4
Wait for propagation

DNS changes can take 15 minutes to 48 hours to propagate worldwide. Check progress at dnschecker.org.

⚠️
All DNS management moves to DigitalOcean

Once you switch to Custom DNS, Namecheap's DNS settings no longer apply. All DNS records (A, TXT, MX, CNAME) are now managed in DigitalOcean → Networking → Domains.

Part 3 — Add DNS Records in DigitalOcean

After adding your domain in DigitalOcean, add these records:

TypeHostnameValuePurpose
A@<YOUR_SERVER_IP>Points root domain (<yourdomain.com>) to your server
Awww<YOUR_SERVER_IP>Points www.<yourdomain.com> to your server
Amail<YOUR_SERVER_IP>Points mail.<yourdomain.com> (for Resend email)
Check propagation

Visit dnschecker.org, enter your domain and select "A" record type. Green checkmarks across the globe mean it's working. Once propagated, your domain will load your Nginx welcome page or your Laravel app.

Part 4 — Add Domain Record in DigitalOcean Dashboard

Also add an A record pointing your droplet hostname to your IP in the droplet's networking settings:

1
DigitalOcean → Networking → Domains → <yourdomain.com>

Under "Create new record" select Type: A

2
Hostname: @ | Will direct to: your Droplet | TTL: 3600

Click Create Record. Repeat for www.

📧

Email Setup (Resend)

ℹ️
Why not just use Gmail SMTP?

DigitalOcean blocks outbound SMTP ports (25, 465, 587) by default on new accounts to prevent spam. This means Gmail SMTP won't work without raising a support ticket. Resend uses the HTTP API instead — no SMTP ports needed, so it works immediately out of the box.

Use Resend for transactional emails — free tier includes 3,000 emails/month. No SMTP port issues.

Step 1 — Create Account & Add Domain

1
Create account at resend.com

Sign up for a free account at resend.com

2
Add a sending subdomain

In Resend dashboard → Domains → Add Domain → enter mail.<yourdomain.com>. Using a subdomain (not your root domain) is best practice — it keeps email reputation separate from your main domain.

3
Add the A record in DigitalOcean first

Before adding to Resend, go to DigitalOcean → Networking → Domains → <yourdomain.com> and add:
Type: A | Hostname: mail | Value: <YOUR_SERVER_IP>

4
Add DNS records Resend gives you

Resend will show you a set of DNS records to verify domain ownership. Add them all in DigitalOcean DNS — see the table below.

5
Create API Key

Resend dashboard → API Keys → Create → choose "Sending Access" (not Full Access) → copy the key

Step 2 — DNS Records to Add in DigitalOcean

After adding your domain in Resend, it will show you these record types to add in DigitalOcean → Networking → Domains → <yourdomain.com>:

TypeHostnameValueWhat it does
Amail.<yourdomain.com><YOUR_SERVER_IP>Points the mail subdomain to your server
TXTresend._domainkey.mail.<yourdomain.com>p=... (from Resend)DKIM — proves emails are signed by you, prevents spoofing
TXTsend.mail.<yourdomain.com>v=spf1 ... ~all (from Resend)SPF — tells mail servers which servers are allowed to send from your domain
MXsend.mail.<yourdomain.com>feedback-smtp.us-east-1.amazonses.comHandles bounce/feedback emails (Resend uses Amazon SES backend)
TXT_dmarc.<yourdomain.com>v=DMARC1; p=none (from Resend)DMARC — policy for what happens when SPF/DKIM fail
⚠️
Copy values exactly from Resend

The DKIM (p=...) and SPF values are unique to your account — always copy them directly from the Resend dashboard. The table above shows the structure; Resend fills in the actual values.

Domain verified?

After adding all records, click "Verify" in Resend. DNS can take 5–30 minutes to propagate. Once verified you'll see "Domain verified: Your domain is ready to send emails."

Step 3 — Install Package & Configure Laravel

Server
composer require resend/resend-laravel

.env Mail Settings

.env
MAIL_MAILER=resend
MAIL_FROM_ADDRESS="no-reply@mail.<yourdomain.com>"
MAIL_FROM_NAME="${APP_NAME}"
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx    # Note: RESEND_API_KEY not RESEND_KEY
⚠️
Variable name is RESEND_API_KEY

The correct env variable is RESEND_API_KEY — not RESEND_KEY. Using the wrong name will throw an "API key is missing" error even if the key is set.

Step 4 — Test Email

Server
php artisan config:clear
php artisan tinker

Mail::raw('Test email', function($m) {
    $m->to('you@gmail.com')->subject('Test');
});

Email Service Comparison

ServiceFree TierNotes
Resend3,000/month✅ Best for Laravel, HTTP API, modern
Brevo300/day✅ Good free tier
Mailgun1,000 first month only⚠️ Then paid
SendGrid100/dayBetter for bulk/marketing emails

🛡️

HTTP Basic Auth (Password Protect Site)

Add a browser password prompt to restrict access — useful while site is under development.

Setup Basic Auth

🖥 Server
# Install htpasswd tool
sudo apt install -y apache2-utils

# Create password file (first time)
sudo htpasswd -c /etc/nginx/.htpasswd  <your-username>

# Add/update user without overwriting file
sudo htpasswd /etc/nginx/.htpasswd <your-username>

Add to Nginx Server Block

📄 Config/etc/nginx/sites-available/<your-app>.conf
server {
    # ... other config ...
    auth_basic "Restricted Access";
    auth_basic_user_file /etc/nginx/.htpasswd;
}

Remove Basic Auth

🖥 Serverremove basic auth
# Remove these two lines from <your-app>.conf:
# auth_basic "Restricted Access";
# auth_basic_user_file /etc/nginx/.htpasswd;

sudo nginx -t
sudo systemctl restart nginx

🔑

File Permissions

ℹ️
Why do permissions matter for Laravel?

Laravel needs to write files to the storage/ and bootstrap/cache/ directories for logs, sessions, file uploads, compiled views, and cache. Nginx runs as the www-data user, not as you. If permissions are wrong, Nginx can't write those files and you'll get 500 errors, blank pages, or "failed to open stream" errors. These commands give both you and Nginx the right access without opening everything up insecurely.

Set correct permissions for Laravel to read/write storage and cache.

ℹ️
Why www-data?

www-data is the system user that Nginx and PHP-FPM run as. Laravel needs to write logs, cache files, and session data to the storage/ folder. By setting www-data as the group owner, both your user and the web server can read/write those directories. Without this, you'll get "Permission denied" errors when the app tries to write files.

🖥 Server/var/www/<your-app>
sudo chown -R $USER:www-data .
sudo find . -type f -exec chmod 664 {} \;
sudo find . -type d -exec chmod 775 {} \;
sudo chgrp -R www-data storage bootstrap/cache
sudo chmod -R ug+rwx storage bootstrap/cache

Fix Storage Permissions (if cache:clear fails)

🖥 Server
sudo chmod -R 775 /var/www/<your-app>/storage
sudo chown -R <your-username>:www-data /var/www/<your-app>/storage

🔁

Git Workflow (Pull Updates)

Pull Latest Changes from GitHub

🖥 Server
# Load SSH key first
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_github

# Pull from branch
cd /var/www/<your-app>
git pull origin production

After Pulling — Deploy Steps

🖥 Server
# 1. Enable maintenance mode — shows friendly "down for maintenance" page to visitors
php artisan down

# 2. Run any new migrations (--force skips the production confirmation prompt)
php artisan migrate --force

# 3. Clear all caches so new code takes effect
php artisan optimize:clear

# 4. Re-cache config, routes and views for production performance
php artisan optimize

# 5. Bring the site back up
php artisan up
ℹ️
storage:link — run once on fresh deploys

If your app stores user-uploaded files, run this once after cloning to create a symlink from public/storagestorage/app/public. Without it, uploaded files won't be accessible from the browser.

php artisan storage:link

View a File in Terminal

🖥 Server
cat /var/www/<your-app>/resources/views/web-lab/index.blade.php

🔗

Persistent GitHub SSH (Server)

The SSH agent on your server stops or expires between sessions, causing git pull to fail with "Permission denied". Fix this permanently with a server-side SSH config file.

⚠️
Without this, you need to run eval + ssh-add every session

This config file makes GitHub SSH authentication persistent so git pull always works.

Create SSH Config on Server

🖥 Server
cd ~/.ssh
sudo nano config

Config File Contents

🖥 Server~/.ssh/config
Host github.com
    User git
    Hostname github.com
    IdentityFile ~/.ssh/id_github
    TCPKeepAlive yes
    IdentitiesOnly yes
    ServerAliveInterval 60

Fix Config File Ownership

🖥 Serverfix ownership
sudo chown <your-username> config     # Change owner from root to <your-username>
ll                           # Verify ownership

Test & Pull

🖥 Server
ssh -T git@github.com                   # Test connection
cd /var/www/<your-app>
git pull origin production              # Should work without ssh-add

🏗️

NPM / Frontend Assets

Install npm dependencies and build frontend assets for production (Vite).

ℹ️
What does npm run build do?

Laravel uses Vite to bundle CSS and JavaScript files. npm install downloads the frontend packages. npm run build compiles and minifies them into the public/build/ folder that Nginx serves. Without running this, your site may have no styles or broken JavaScript.

🖥 Server/var/www/<your-app>
npm install          # Install dependencies
npm run build        # Build assets for production

If TypeScript Permission Denied

🖥 Server
sudo chmod +x ./node_modules/.bin/tsc
npm run build        # Try again

Restart PHP-FPM

🖥 Serverafter config changes
sudo systemctl restart php8.3-fpm
sudo systemctl status php8.3-fpm

🔧

Troubleshooting

502 Bad Gateway

Means PHP-FPM is not installed or not running.

🖥 Server
# Check installed modules
php8.3 -m

# Install php-fpm if missing
sudo apt install -y php8.3-fpm

# Restart fpm
sudo systemctl restart php8.3-fpm

# Refresh browser

git pull Permission Denied (publickey)

🖥 Serverssh-agent expired
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_github
git pull origin production
Permanent fix

Set up the Persistent GitHub SSH config (see section above) so you never need to reload the SSH agent manually.

Clear Terminal

OSCommand
Windowscls
Linux / Serverclear
MacCommand + K

DB Access Denied (Laravel)

📋 Checklist
# 1. Wrap password in quotes in .env
DB_PASSWORD="your@password"

# 2. Create user for both localhost and 127.0.0.1
sudo mysql
CREATE USER '<your-username>'@'127.0.0.1' IDENTIFIED BY 'yourpassword';
GRANT ALL PRIVILEGES ON <your_database>.* TO '<your-username>'@'127.0.0.1';
FLUSH PRIVILEGES;

# 3. Clear Laravel config cache
php artisan config:clear

Nginx Conflicting Server Name Warning

🖥 Serverremove default
sudo rm /etc/nginx/sites-available/default
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl restart nginx

Composer Security Vulnerabilities

🖥 Server
composer audit      # Check vulnerabilities
composer update     # Fix them
composer audit      # Verify fixed

📋

Quick Cheatsheet

Vim / Nano Editor

ActionVimNano
Save & Exit:wq + EnterCtrl+X → Y → Enter
Exit without saving:q! + EnterCtrl+X → N
Just save:w + EnterCtrl+S

Artisan Commands

🖥 Serverartisan commands
# ── Setup ────────────────────────────────────────────────────
php artisan key:generate       # Generate APP_KEY (run once after clone)
php artisan storage:link       # Create public/storage symlink (run once on fresh deploy)

# ── Database ─────────────────────────────────────────────────
php artisan migrate            # Run pending migrations
php artisan migrate --force    # Run in production without confirmation prompt
php artisan migrate:rollback   # Undo the last batch of migrations
php artisan migrate:status     # Show which migrations have / haven't run
php artisan db:seed            # Run database seeders

# ── Cache — clear ────────────────────────────────────────────
php artisan optimize:clear     # Clear all caches at once (config + route + view + app)
php artisan config:clear       # Clear config cache only
php artisan cache:clear        # Clear application cache only
php artisan view:clear         # Clear compiled Blade views only
php artisan route:clear        # Clear route cache only

# ── Cache — build (production performance) ───────────────────
php artisan optimize           # Cache config + routes + views in one command
php artisan config:cache       # Cache config only
php artisan route:cache        # Cache routes only (faster routing)
php artisan view:cache         # Pre-compile all Blade views

# ── Maintenance mode ─────────────────────────────────────────
php artisan down               # Put site in maintenance mode (shows 503 page)
php artisan up                 # Bring site back online

# ── Debugging ────────────────────────────────────────────────
php artisan route:list         # List all registered routes
php artisan tinker             # Interactive PHP/Laravel shell
tail -f storage/logs/laravel.log  # Stream live error log

Service Management

🖥 Serversystemctl
sudo systemctl status nginx
sudo systemctl restart nginx
sudo systemctl status mysql
sudo systemctl restart mysql
sudo systemctl status ssh
sudo systemctl restart ssh

Check DNS Propagation

🌐 Browser
https://dnschecker.org

Check Open Port

🖥 Server
telnet smtp.gmail.com 587    # Test if port is open