How to Deploy a Laravel App on a DigitalOcean VPS From Scratch
Complete guide to setting up a DigitalOcean Ubuntu droplet with PHP, MySQL, Nginx, and Laravel — covering everything from server creation to production deployment.
No results found. Try a different search term.
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.
Create a DigitalOcean 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
Log in to digitalocean.com. At the top of the dashboard click the green Create button, then select Droplets from the dropdown.
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.
Ubuntu LTS (Long Term Support) is the most popular and best-supported Linux distro for web servers. Always pick the latest LTS version.
For most Laravel apps, the Basic plan is sufficient to start. You can always upgrade later without losing data.
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.
Choose Password for initial setup — we will disable root password login and switch to SSH key authentication after the server is configured.
The hostname is just a label for your droplet — it doesn't affect your domain. Use something descriptive like prod-server or prod-server.
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.
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
| Setting | Recommended | Notes |
|---|---|---|
| OS | Ubuntu 24.04 LTS | Latest stable, 5-year support |
| Plan | Basic — Premium AMD or Premium Intel | NVMe SSD, better performance than Regular |
| Size | 2GB RAM / 1 vCPU | ~$14–$16/mo depending on region & CPU type. Good starting point for Laravel |
| Region | Closest to your users | NYC, SFO, LON, FRA, SGP etc. — note: not all CPU types available in all regions |
| Auth Method | Password (initially) | Switch to SSH keys after setup |
Connect to Your New Droplet
ssh root@<YOUR_SERVER_IP>
# First time: type "yes" to accept the fingerprint
# Then enter your root password
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:
| Tool | Role | Analogy |
|---|---|---|
| DigitalOcean | Provides the physical server (VPS) running 24/7 in the cloud | The building your office is in |
| Ubuntu (OS) | The operating system the server runs on | The building's power and plumbing |
| Nginx | Web server — receives HTTP/HTTPS requests and routes them to PHP | The receptionist at the front desk |
| PHP 8.3 + FPM | Executes your Laravel application code | The staff doing the actual work |
| MySQL | Stores all your application data (users, records, etc.) | The filing cabinet |
| Composer | Installs and manages PHP packages (Laravel, etc.) | The supply department |
| Node.js + npm | Compiles CSS/JS frontend assets at build time | The printing press — used once, then shelved |
| Certbot / SSL | Encrypts all traffic between browser and server (HTTPS) | The locks on the doors |
| Git / GitHub | Version control — deploy new code via git pull | The delivery service for code updates |
| SSH Keys | Secure, password-free authentication to your server | A key card instead of a password at the door |
| Resend | Sends transactional emails reliably via HTTP API | The postal service |
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
ssh root@<YOUR_SERVER_IP>
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)
Click "Access" in the left sidebar
DigitalOcean emails you a temporary password
Log in with root + new password. You'll be prompted to change it on first login
Restart SSH Service
sudo systemctl restart ssh
On Ubuntu the SSH service is named ssh not sshd.
Edit SSH Config
sudo nano /etc/ssh/sshd_config
# Set these values:
PasswordAuthentication yes
PermitRootLogin yes
Create Sudo User & Disable 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.
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
adduser user <your-username> # Creates user and sets password
Add User to Sudo Group
usermod -aG sudo sudo <your-username> # Grants sudo privileges
Switch to New User
su - <your-username> # Switch to new user exit # Log out from user session exit # Log out from server
Login with New User
ssh <your-username>@<YOUR_SERVER_IP>
Disable Root Login (after SSH key is set up)
sudo nano /etc/ssh/sshd_config
# Update these values:
PermitRootLogin no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication no
PermitEmptyPasswords no
Make sure you can log in with your SSH key as the new user BEFORE disabling password auth. Otherwise you'll be locked out.
sudo systemctl restart ssh
Configure SSH Access (authorized_keys setup)
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
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.
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).
ssh-keygen -t ed25519 -C "your_email@example.com"
| Algorithm | Command | Notes |
|---|---|---|
| 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)
type "%USERPROFILE%\.ssh\id_ed25519.pub" | ssh root@<YOUR_SERVER_IP> "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
The type command works in CMD. In PowerShell use Get-Content or run CMD first.
Fix SSH Key Permissions on Server
chmod 600 ~/.ssh/id_github
Add Key to SSH Agent (Windows PowerShell Admin)
Set-Service ssh-agent -StartupType Automatic Start-Service ssh-agent ssh-add "$env:USERPROFILE\.ssh\id_ed25519"
Test GitHub SSH Connection
ssh -T git@github.com
# Expected: Hi username! You've successfully authenticated...
Copy SSH Key from Local to Server (SCP)
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
eval "$(ssh-agent -s)" ssh-add ~/.ssh/id_github
SSH Config Shortcut
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.
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)
New-Item "$env:USERPROFILE\.ssh\config" -ItemType File notepad "$env:USERPROFILE\.ssh\config"
Config File Contents
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
| Field | What it does | Example |
|---|---|---|
| Host | The alias/shortcut name you type in the terminal — can be anything you want | prod-server |
| HostName | The actual IP address or domain of the server — must be IP only, no username | 143.244.152.100 |
| User | The username to log in as on the server | <your-username> |
| IdentityFile | Path to your private key file — tells SSH which key to use for this server | C:\Users\<YourName>\.ssh\id_github |
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
ssh prod-server # Instead of: ssh -i "path/to/key" <your-username>@<YOUR_SERVER_IP>
Move SSH Files to Default Location
# 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
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.
# 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
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
sudo add-apt-repository ppa:ondrej/php sudo apt update
Install PHP + All Laravel Extensions
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
| Extension | Purpose |
|---|---|
| php8.3-cli | Run PHP from command line (artisan) |
| php8.3-fpm | PHP FastCGI for Nginx |
| php8.3-mysql | MySQL database connection |
| php8.3-sqlite3 | SQLite support |
| php8.3-curl | HTTP requests |
| php8.3-mbstring | String handling — required by Laravel |
| php8.3-xml | XML + DOM parsing |
| php8.3-zip | Zip file handling |
| php8.3-gd | Image processing |
| php8.3-intl | Internationalization |
| php8.3-bcmath | Precise math calculations |
| php8.3-opcache | PHP performance caching (production) |
| php8.3-common | Includes tokenizer, fileinfo, ctype (auto-installed) |
Verify Installation
php -v # Check PHP version php -m # List all loaded extensions
Install unzip (important for Composer)
sudo apt install -y unzip
Configure PHP-FPM Upload Limits
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.
sudo nano /etc/php/8.3/fpm/php.ini
Find and update these four values (use Ctrl+W to search inside nano):
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:
sudo systemctl restart php8.3-fpm
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.
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.
curl -sS https://getcomposer.org/installer | php sudo mv composer.phar /usr/local/bin/composer composer --version
Using sudo apt install composer installs an older version. Always install from getcomposer.org for the latest stable release.
Composer Security Audit
composer audit # Check for vulnerabilities composer update # Update all packages to fix vulnerabilities
MySQL
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
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
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
# 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;
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.
Always wrap passwords in double quotes in .env: DB_PASSWORD="your@pass#word"
Test Connection
mysql -u <your-username> -p -h 127.0.0.1 <your_database>
Allow Remote Connections (for MySQLYog etc.)
# 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
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
sudo apt install -y nginx sudo systemctl status nginx
Nginx Service Commands
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
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).
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
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
/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
# 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 .
Copy your GitHub SSH key to the server and load it via ssh-agent before cloning. See SSH Keys section.
Fix Double Nested Folder
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
cd /var/www/<your-app> composer install
Create .env File
# If .env.example exists: cp .env.example .env # If not (manually create): nano .env
Minimal .env for Production
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
php artisan key:generate php artisan migrate
Clear All Caches
# 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
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
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 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
sudo nano /etc/nginx/sites-available/<your-app>.conf
Laravel Server Block
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;
}
}
If file uploads fail with a 413 error, there are two independent limits that both need to be raised:
- Nginx —
client_max_body_sizeinside theserver {}block (default: 1 MB). Add the line above and restart Nginx. - PHP-FPM —
upload_max_filesizeandpost_max_sizein/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)
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
sudo rm /etc/nginx/sites-available/default sudo rm /etc/nginx/sites-enabled/default sudo nginx -t sudo systemctl restart nginx
DNS Records (DigitalOcean)
| Type | Hostname | Value |
|---|---|---|
| A | @ | <YOUR_SERVER_IP> |
| A | www | <YOUR_SERVER_IP> |
| A | <YOUR_SERVER_IP> |
SSL Certificate (Let's Encrypt)
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.
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
sudo apt install -y certbot python3-certbot-nginx
Get Certificate
sudo certbot --nginx -d <your-app>.<yourdomain.com> -d www.<your-app>.<yourdomain.com>
Verify Auto-renewal
sudo certbot renew --dry-run
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:
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)
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
Click Add Domain, enter <yourdomain.com> and click Add Domain.
DigitalOcean uses these three nameservers — you'll need them for Namecheap:
ns1.digitalocean.com ns2.digitalocean.com ns3.digitalocean.com
Part 2 — Point Namecheap to DigitalOcean
Find your domain and click Manage.
Change the dropdown from "Namecheap BasicDNS" to "Custom DNS".
Add all three: ns1.digitalocean.com, ns2.digitalocean.com, ns3.digitalocean.com and click the checkmark to save.
DNS changes can take 15 minutes to 48 hours to propagate worldwide. Check progress at dnschecker.org.
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:
| Type | Hostname | Value | Purpose |
|---|---|---|---|
| A | @ | <YOUR_SERVER_IP> | Points root domain (<yourdomain.com>) to your server |
| A | www | <YOUR_SERVER_IP> | Points www.<yourdomain.com> to your server |
| A | <YOUR_SERVER_IP> | Points mail.<yourdomain.com> (for Resend email) |
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:
Under "Create new record" select Type: A
Click Create Record. Repeat for www.
Email Setup (Resend)
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
Sign up for a free account at resend.com
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.
Before adding to Resend, go to DigitalOcean → Networking → Domains → <yourdomain.com> and add:Type: A | Hostname: mail | Value: <YOUR_SERVER_IP>
Resend will show you a set of DNS records to verify domain ownership. Add them all in DigitalOcean DNS — see the table below.
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>:
| Type | Hostname | Value | What it does |
|---|---|---|---|
| A | mail.<yourdomain.com> | <YOUR_SERVER_IP> | Points the mail subdomain to your server |
| TXT | resend._domainkey.mail.<yourdomain.com> | p=... (from Resend) | DKIM — proves emails are signed by you, prevents spoofing |
| TXT | send.mail.<yourdomain.com> | v=spf1 ... ~all (from Resend) | SPF — tells mail servers which servers are allowed to send from your domain |
| MX | send.mail.<yourdomain.com> | feedback-smtp.us-east-1.amazonses.com | Handles 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 |
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.
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
composer require resend/resend-laravel
.env Mail Settings
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
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
php artisan config:clear
php artisan tinker
Mail::raw('Test email', function($m) {
$m->to('you@gmail.com')->subject('Test');
});
Email Service Comparison
| Service | Free Tier | Notes |
|---|---|---|
| Resend | 3,000/month | ✅ Best for Laravel, HTTP API, modern |
| Brevo | 300/day | ✅ Good free tier |
| Mailgun | 1,000 first month only | ⚠️ Then paid |
| SendGrid | 100/day | Better 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
# 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
server {
# ... other config ...
auth_basic "Restricted Access";
auth_basic_user_file /etc/nginx/.htpasswd;
}
Remove 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
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.
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.
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)
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
# 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
# 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
If your app stores user-uploaded files, run this once after cloning to create a symlink from public/storage → storage/app/public. Without it, uploaded files won't be accessible from the browser.
php artisan storage:link
View a File in Terminal
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.
This config file makes GitHub SSH authentication persistent so git pull always works.
Create SSH Config on Server
cd ~/.ssh sudo nano config
Config File Contents
Host github.com
User git
Hostname github.com
IdentityFile ~/.ssh/id_github
TCPKeepAlive yes
IdentitiesOnly yes
ServerAliveInterval 60
Fix Config File Ownership
sudo chown <your-username> config # Change owner from root to <your-username> ll # Verify ownership
Test & Pull
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).
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.
npm install # Install dependencies npm run build # Build assets for production
If TypeScript Permission Denied
sudo chmod +x ./node_modules/.bin/tsc
npm run build # Try again
Restart PHP-FPM
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.
# 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)
eval "$(ssh-agent -s)" ssh-add ~/.ssh/id_github git pull origin production
Set up the Persistent GitHub SSH config (see section above) so you never need to reload the SSH agent manually.
Clear Terminal
| OS | Command |
|---|---|
| Windows | cls |
| Linux / Server | clear |
| Mac | Command + K |
DB Access Denied (Laravel)
# 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
sudo rm /etc/nginx/sites-available/default sudo rm /etc/nginx/sites-enabled/default sudo nginx -t sudo systemctl restart nginx
Composer Security Vulnerabilities
composer audit # Check vulnerabilities composer update # Fix them composer audit # Verify fixed
Quick Cheatsheet
Vim / Nano Editor
| Action | Vim | Nano |
|---|---|---|
| Save & Exit | :wq + Enter | Ctrl+X → Y → Enter |
| Exit without saving | :q! + Enter | Ctrl+X → N |
| Just save | :w + Enter | Ctrl+S |
Artisan 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
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
https://dnschecker.org
Check Open Port
telnet smtp.gmail.com 587 # Test if port is open