I wrote this as the kind of guide I wish I had when I was first setting up production servers. We'll cover not just the how but the why behind every security decision — so you actually understand your server, not just copy-paste commands blindly.
This guide applies to both DigitalOcean Droplets and Hostinger VPS running Ubuntu 22.04 LTS. The commands are nearly identical across both platforms.
1. Prerequisites
Before you start, make sure you have the following ready:
- A fresh Ubuntu 22.04 LTS VPS on DigitalOcean or Hostinger (minimum 1GB RAM recommended for Laravel)
- Your domain name pointed to your server's IP address (A record configured in your DNS)
- Your Laravel project in a Git repository (GitHub, GitLab, or Bitbucket)
- SSH access to your server (DigitalOcean and Hostinger both give you root credentials on first setup)
- A local terminal — Mac/Linux Terminal or Windows WSL/PuTTY
A note on Ubuntu version: This guide uses Ubuntu 22.04 LTS (Jammy Jellyfish). The commands also work on Ubuntu 20.04. If you're on CentOS or Debian, the package manager commands will differ slightly.
2. Initial Server Access
When your VPS is created, you'll get a root password (Hostinger) or you'll have set up an SSH key (DigitalOcean). SSH into your server for the first time:
ssh root@YOUR_SERVER_IPIf you're using an SSH key with DigitalOcean:
ssh -i ~/.ssh/your_key root@YOUR_SERVER_IPOnce you're in, the first thing to do is update all system packages. Never skip this step on a fresh server:
apt update && apt upgrade -yThis updates the package list and upgrades all installed packages to their latest versions. Security patches are included here — running a server with outdated packages is like leaving your front door unlocked.
3. Create a Non-Root Sudo User
Working as root all the time is dangerous. A single typo — like rm -rf / — can wipe your entire server with no warning or confirmation. Create a separate user with sudo privileges and disable direct root login.
# Create a new user (replace 'zeeshan' with your username)
\nadduser zeeshan
\n
\n# Add the user to the sudo group
\nusermod -aG sudo zeeshanNow copy your SSH keys to the new user so you can log in as them:
# While still logged in as root
\nrsync --archive --chown=zeeshan:zeeshan ~/.ssh /home/zeeshanOpen a new terminal window and test logging in as your new user before closing the root session:
ssh zeeshan@YOUR_SERVER_IPConfirm sudo works:
sudo whoami
\n# Should output: rootNow disable root login via SSH. Edit the SSH config:
sudo nano /etc/ssh/sshd_configFind and change these lines:
PermitRootLogin no
\nPasswordAuthentication noSetting PasswordAuthentication no means only SSH key holders can log in — no password brute-forcing possible. Save the file (Ctrl+X, then Y, then Enter) and restart SSH:
sudo systemctl restart sshd4. Change the Default SSH Port — And Why It Matters
By default, SSH listens on port 22. This is universally known. The moment a server goes online with port 22 open, automated bots from all over the internet start hammering it with login attempts — trying common usernames and passwords thousands of times per hour. This is not theoretical. Check your auth logs on any fresh server after 10 minutes:
sudo cat /var/log/auth.log | grep "Failed password"You'll see hundreds of failed attempts from IPs all over the world. Changing the SSH port to a non-standard number won't make you invincible, but it eliminates the vast majority of automated attacks because bots scan port 22 specifically. It's a simple change that immediately reduces your attack surface significantly.
How to Change the SSH Port
Edit the SSH daemon config:
sudo nano /etc/ssh/sshd_configFind the line:
#Port 22Uncomment it and change it to a port number between 1024 and 65535. Pick something non-obvious. Avoid common alternatives like 2222 (bots scan those too):
Port 4827Before restarting SSH, add the new port to your firewall (UFW) so you don't lock yourself out:
sudo ufw allow 4827/tcpNow restart SSH:
sudo systemctl restart sshdOpen a new terminal window and test connecting on the new port before closing your current session:
ssh -p 4827 zeeshan@YOUR_SERVER_IPOnce confirmed working, you can close the old session. From now on, always connect with -p 4827 (or whatever port you chose). To make this permanent on your local machine, add it to your SSH config:
# On your LOCAL machine, edit ~/.ssh/config
\nHost myserver
\n HostName YOUR_SERVER_IP
\n User zeeshan
\n Port 4827
\n IdentityFile ~/.ssh/your_keyNow you can simply type ssh myserver to connect.
Further reading: SSH Academy — Changing the SSH Port
5. Disable Ping on Your Server IP — And Why You Should
When someone pings your server IP and gets a response, they've confirmed two things: the IP is active, and a server is running there. This is the first step in most network reconnaissance. Bots and attackers use ping sweeps to discover live servers across IP ranges. Dropping ICMP echo requests makes your server effectively invisible to these automated scans.
This won't stop a determined attacker who already knows your IP, but it does reduce your exposure to opportunistic scanners significantly — and on a server that doesn't need to respond to diagnostic pings from the public internet, there's no reason to leave it enabled.
How to Block Ping with UFW
Edit the UFW before.rules file:
sudo nano /etc/ufw/before.rulesFind this section near the top:
# ok icmp codes for INPUT
\n-A ufw-before-input -p icmp --icmp-type destination-unreachable -j ACCEPT
\n-A ufw-before-input -p icmp --icmp-type time-exceeded -j ACCEPT
\n-A ufw-before-input -p icmp --icmp-type parameter-problem -j ACCEPT
\n-A ufw-before-input -p icmp --icmp-type echo-request -j ACCEPTChange the last line from ACCEPT to DROP:
-A ufw-before-input -p icmp --icmp-type echo-request -j DROPSave and reload UFW:
sudo ufw reloadTest it from your local machine:
ping YOUR_SERVER_IP
\n# Should now show: Request timeoutNote: Keep destination-unreachable and time-exceeded as ACCEPT — these are needed for proper TCP/IP routing and MTU discovery. Only drop the echo-request.
6. Install & Configure fail2ban — Your First Line of Defence
Even with a non-standard SSH port and key-only authentication, it's good practice to run fail2ban. fail2ban monitors your log files in real-time and automatically bans IP addresses that show malicious behaviour — like repeated failed SSH login attempts, or repeated 404 errors on your web server.
Think of it as an automated bouncer. After a configurable number of failed attempts within a time window, fail2ban adds an iptables rule to block that IP for a set period. This protects not just SSH but also your web application, email, and any other service you configure it to watch.
Install fail2ban
sudo apt install fail2ban -yConfigure fail2ban
Never edit /etc/fail2ban/jail.conf directly — it gets overwritten on updates. Instead, create a local override file:
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
\nsudo nano /etc/fail2ban/jail.localFind the [DEFAULT] section and set these values:
[DEFAULT]
\n# Ban an IP for 1 hour (in seconds)
\nbantime = 3600
\n
\n# Look for failures within a 10-minute window
\nfindtime = 600
\n
\n# Ban after 5 failed attempts
\nmaxretry = 5
\n
\n# Your own IP to never ban (replace with your home/office IP)
\nignoreip = 127.0.0.1/8 ::1 YOUR_HOME_IPNow find the [sshd] section and enable it with your custom port:
[sshd]
\nenabled = true
\nport = 4827
\nlogpath = %(sshd_log)s
\nbackend = %(sshd_backend)sIf you want fail2ban to also protect your Nginx web server from brute-force attempts:
[nginx-http-auth]
\nenabled = true
\n
\n[nginx-limit-req]
\nenabled = true
\nlogpath = /var/log/nginx/error.logStart and enable fail2ban:
sudo systemctl start fail2ban
\nsudo systemctl enable fail2banCheck its status and see active jails:
sudo fail2ban-client status
\nsudo fail2ban-client status sshdTo manually unban an IP if you accidentally lock yourself out:
sudo fail2ban-client set sshd unbanip THE_IP_ADDRESSFurther reading: fail2ban Official Manual
7. Configure UFW Firewall
UFW (Uncomplicated Firewall) is the standard firewall tool on Ubuntu. The rule is simple: deny everything by default, then only allow what you specifically need.
# Set default rules
\nsudo ufw default deny incoming
\nsudo ufw default allow outgoing
\n
\n# Allow your custom SSH port (use whatever port you chose in Step 4)
\nsudo ufw allow 4827/tcp
\n
\n# Allow HTTP and HTTPS
\nsudo ufw allow 80/tcp
\nsudo ufw allow 443/tcp
\n
\n# Enable the firewall
\nsudo ufw enable
\n
\n# Check status
\nsudo ufw status verboseYou should see only ports 4827, 80, and 443 open. Nothing else. If your app needs MySQL from an external service, you can open port 3306 restricted to a specific IP:
sudo ufw allow from TRUSTED_IP to any port 3306Never open port 3306 to the world — that's a common and serious mistake.
8. Install the LEMP Stack (Nginx + PHP + MySQL)
LEMP stands for Linux, Nginx, MySQL (or MariaDB), and PHP. This is the standard stack for Laravel VPS deployment.
Install Nginx
sudo apt install nginx -y
\nsudo systemctl start nginx
\nsudo systemctl enable nginxInstall PHP and Required Extensions
Laravel 10+ requires PHP 8.1 or higher. We'll install PHP 8.2 with all extensions Laravel needs:
# Add the Ondrej PHP PPA for latest PHP versions
\nsudo apt install software-properties-common -y
\nsudo add-apt-repository ppa:ondrej/php -y
\nsudo apt update# Install PHP 8.2 and Laravel's required extensions
\nsudo apt install php8.2 php8.2-fpm php8.2-mysql php8.2-mbstring php8.2-xml \\
\nphp8.2-bcmath php8.2-curl php8.2-zip php8.2-gd php8.2-intl php8.2-redis \\
\nphp8.2-tokenizer php8.2-fileinfo -yVerify PHP is installed:
php -vEnable OPcache for significantly faster PHP execution (this alone can reduce response times by 50-70%):
sudo nano /etc/php/8.2/fpm/php.iniFind and set these OPcache values:
opcache.enable=1
\nopcache.memory_consumption=128
\nopcache.interned_strings_buffer=8
\nopcache.max_accelerated_files=10000
\nopcache.revalidate_freq=2
\nopcache.fast_shutdown=1Install MySQL
sudo apt install mysql-server -y
\nsudo mysql_secure_installationThe secure installation script will ask you to set a root password, remove anonymous users, disable remote root login, and remove the test database. Answer yes to all of these.
Create a database and user for your Laravel app:
sudo mysql -u root -p-- Inside MySQL shell
\nCREATE DATABASE your_app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
\nCREATE USER 'your_app_user'@'localhost' IDENTIFIED BY 'StrongPassword123!';
\nGRANT ALL PRIVILEGES ON your_app_db.* TO 'your_app_user'@'localhost';
\nFLUSH PRIVILEGES;
\nEXIT;Install Composer
curl -sS https://getcomposer.org/installer | php
\nsudo mv composer.phar /usr/local/bin/composer
\ncomposer --versionInstall Git
sudo apt install git -y9. Configure Nginx Virtual Host for Laravel
This is one of the most critical steps in a Laravel server deployment. A correct Nginx server block does several things: it points the document root to Laravel's public/ directory (not the project root — exposing your project root is a serious security risk), handles PHP via PHP-FPM, and routes all requests through Laravel's index.php front controller.
Create a new Nginx server block for your domain:
sudo nano /etc/nginx/sites-available/yourdomain.comPaste this complete configuration:
server {
\n listen 80;
\n listen [::]:80;
\n
\n server_name yourdomain.com www.yourdomain.com;
\n
\n # IMPORTANT: Document root points to Laravel's public/ directory
\n # Never point this to your project root
\n root /var/www/yourdomain.com/public;
\n
\n index index.php index.html;
\n
\n # Security headers — protect against common web attacks
\n add_header X-Frame-Options "SAMEORIGIN";
\n add_header X-Content-Type-Options "nosniff";
\n add_header X-XSS-Protection "1; mode=block";
\n add_header Referrer-Policy "strict-origin-when-cross-origin";
\n
\n # Hide Nginx version from response headers
\n server_tokens off;
\n
\n # Maximum upload size — match your php.ini setting
\n client_max_body_size 64M;
\n
\n # Laravel's front controller pattern
\n # All requests that don't match a real file go to index.php
\n location / {
\n try_files $uri $uri/ /index.php?$query_string;
\n }
\n
\n # Block access to hidden files (like .env, .git)
\n location ~ /\\. {
\n deny all;
\n access_log off;
\n log_not_found off;
\n }
\n
\n # PHP-FPM configuration
\n # Using Unix socket is faster than TCP (127.0.0.1:9000)
\n location ~ \\.php$ {
\n include snippets/fastcgi-php.conf;
\n fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
\n fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
\n include fastcgi_params;
\n
\n # Pass real IP to PHP (useful for logging and rate limiting)
\n fastcgi_param REMOTE_ADDR $remote_addr;
\n }
\n
\n # Cache static assets aggressively
\n location ~* \\.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg)$ {
\n expires 1y;
\n add_header Cache-Control "public, immutable";
\n access_log off;
\n }
\n
\n # Don't log favicon or robots.txt requests
\n location = /favicon.ico { access_log off; log_not_found off; }
\n location = /robots.txt { access_log off; log_not_found off; }
\n
\n error_log /var/log/nginx/yourdomain.com_error.log;
\n access_log /var/log/nginx/yourdomain.com_access.log;
\n}Why These Configuration Choices Matter
root /var/www/.../public— Pointing topublic/means your.envfile, application code, andvendor/directory are never directly accessible from the web. This is fundamental Laravel security.- Security headers —
X-Frame-Optionsprevents clickjacking attacks.X-Content-Type-Optionsstops MIME sniffing. These cost you nothing but protect your users. - Unix socket vs TCP —
unix:/var/run/php/php8.2-fpm.sockis faster than127.0.0.1:9000because it skips the TCP stack entirely. Use socket when Nginx and PHP-FPM are on the same server (which they almost always are). location ~ /\\.— This blocks access to any hidden file or directory. Without this, someone could potentially accessyourdomain.com/.envdirectly.
Enable the site by creating a symlink and test the configuration:
# Enable the site
\nsudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
\n
\n# Test Nginx config for syntax errors — always do this before reloading
\nsudo nginx -t
\n
\n# If the test passes, reload Nginx
\nsudo systemctl reload nginxAlways run sudo nginx -t before reloading. A config error with a running reload can take your server down.
10. Alternative: Apache Virtual Host for Laravel
If your hosting environment uses Apache (common on cPanel and some Hostinger plans), here's how to configure a virtual host for Laravel. The key difference from Nginx is that Apache uses .htaccess files for URL rewriting, which Laravel already includes in its public/ directory.
First, enable the required Apache modules:
sudo a2enmod rewrite
\nsudo a2enmod headers
\nsudo a2enmod ssl
\nsudo systemctl restart apache2Install PHP and its modules for Apache:
sudo apt install libapache2-mod-php8.2 -yCreate the virtual host configuration:
sudo nano /etc/apache2/sites-available/yourdomain.com.conf<VirtualHost *:80>
\n ServerName yourdomain.com
\n ServerAlias www.yourdomain.com
\n
\n # Document root points to Laravel's public/ directory
\n DocumentRoot /var/www/yourdomain.com/public
\n
\n # Security: hide Apache version
\n ServerTokens Prod
\n ServerSignature Off
\n
\n # Security headers
\n Header always set X-Frame-Options "SAMEORIGIN"
\n Header always set X-Content-Type-Options "nosniff"
\n Header always set X-XSS-Protection "1; mode=block"
\n Header always set Referrer-Policy "strict-origin-when-cross-origin"
\n
\n <Directory /var/www/yourdomain.com/public>
\n # AllowOverride All is required for Laravel's .htaccess to work
\n # Without this, URL rewriting breaks and you get 404 errors
\n AllowOverride All
\n Require all granted
\n
\n Options -Indexes -MultiViews
\n </Directory>
\n
\n # Block access to sensitive files
\n <FilesMatch "^\\.env|composer\\.(json|lock)$">
\n Require all denied
\n </FilesMatch>
\n
\n ErrorLog ${APACHE_LOG_DIR}/yourdomain.com_error.log
\n CustomLog ${APACHE_LOG_DIR}/yourdomain.com_access.log combined
\n</VirtualHost>Why AllowOverride All is Critical for Laravel on Apache
Laravel's public/.htaccess file handles URL rewriting — it redirects all requests to index.php so Laravel's router can handle them. Without AllowOverride All, Apache ignores the .htaccess file entirely, and every route except the homepage returns a 404. This is the single most common Laravel/Apache misconfiguration.
Enable the site:
sudo a2ensite yourdomain.com.conf
\n
\n# Disable the default site if you haven't already
\nsudo a2dissite 000-default.conf
\n
\n# Test Apache config
\nsudo apache2ctl configtest
\n
\n# Reload Apache
\nsudo systemctl reload apache211. Deploy Your Laravel Application
Now let's get your actual application on the server. Create the web directory and clone your repository:
# Create the directory for your app
\nsudo mkdir -p /var/www/yourdomain.com
\n
\n# Give your user ownership
\nsudo chown -R $USER:$USER /var/www/yourdomain.com
\n
\n# Clone your repository
\ncd /var/www
\ngit clone https://github.com/yourusername/your-repo.git yourdomain.com
\n
\ncd yourdomain.comInstall Composer Dependencies
# --no-dev skips development dependencies (testing tools, debugbars, etc.)
\n# --optimize-autoloader generates a faster class autoloader for production
\ncomposer install --optimize-autoloader --no-devSet Up Environment File
cp .env.example .env
\nnano .envConfigure these critical values in your .env:
APP_NAME="Your App Name"
\nAPP_ENV=production
\nAPP_KEY= # Leave blank — generate below
\nAPP_DEBUG=false # CRITICAL: always false in production
\nAPP_URL=https://yourdomain.com
\n
\nLOG_CHANNEL=stack
\nLOG_LEVEL=error # Only log errors in production, not debug info
\n
\nDB_CONNECTION=mysql
\nDB_HOST=127.0.0.1
\nDB_PORT=3306
\nDB_DATABASE=your_app_db
\nDB_USERNAME=your_app_user
\nDB_PASSWORD=StrongPassword123!
\n
\nCACHE_DRIVER=file # Change to redis if you have Redis installed
\nQUEUE_CONNECTION=database # Or redis for better performance
\nSESSION_DRIVER=fileGenerate the application key:
php artisan key:generateSet Correct File Permissions
This is where many deployments break. The web server (running as www-data on Ubuntu) needs write access to storage/ and bootstrap/cache/, but nothing else should be writable by the web server:
# Set ownership — your user owns the files, www-data is the group
\nsudo chown -R $USER:www-data /var/www/yourdomain.com
\n
\n# Directories need execute permission to be traversable
\nsudo find /var/www/yourdomain.com -type d -exec chmod 755 {} \\;
\n
\n# Files should be readable but not executable
\nsudo find /var/www/yourdomain.com -type f -exec chmod 644 {} \\;
\n
\n# Storage and cache must be writable by www-data
\nsudo chmod -R 775 /var/www/yourdomain.com/storage
\nsudo chmod -R 775 /var/www/yourdomain.com/bootstrap/cacheRun Database Migrations
php artisan migrate --forceThe --force flag is required in production because Laravel asks for confirmation before running migrations on a production environment. Only use this when you're sure about your migration.
Seed the Database (if needed)
php artisan db:seed --forceLaravel Performance Optimizations
Run all of these on every deployment. They cache your routes, config, and views so Laravel doesn't have to re-parse them on every request:
# Cache configuration — reads .env once, caches the result
\nphp artisan config:cache
\n
\n# Cache routes — pre-compiles your routes/web.php and routes/api.php
\nphp artisan route:cache
\n
\n# Cache views — pre-compiles Blade templates
\nphp artisan view:cache
\n
\n# Generate optimized autoloader
\ncomposer dump-autoload --optimizeImportant: After running config:cache, your .env file is no longer read directly. Always re-run php artisan config:cache after changing any .env value, or clear the cache first with php artisan config:clear.
Create the storage symlink so uploaded files are publicly accessible:
php artisan storage:link12. Install SSL Certificate with Let's Encrypt
There is no reason to run a production application without HTTPS in 2024. Let's Encrypt provides free SSL certificates with automatic renewal. Google also penalises non-HTTPS sites in search rankings.
# Install Certbot and the Nginx plugin
\nsudo apt install certbot python3-certbot-nginx -y
\n# Obtain and automatically configure SSL for your domain
\nsudo certbot --nginx -d yourdomain.com -d www.yourdomain.comCertbot will ask for your email address (for renewal reminders) and whether to redirect HTTP to HTTPS. Choose option 2 — Redirect. This automatically updates your Nginx config to handle HTTPS and redirect all HTTP traffic.
For Apache, use the Apache plugin instead:
sudo apt install python3-certbot-apache -y
\nsudo certbot --apache -d yourdomain.com -d www.yourdomain.comVerify Auto-Renewal
Let's Encrypt certificates expire every 90 days. Certbot installs a systemd timer that renews them automatically. Test it:
sudo certbot renew --dry-runIf the dry run succeeds, your renewal is properly configured.
Update Laravel APP_URL
Now that HTTPS is active, make sure your .env reflects it:
APP_URL=https://yourdomain.comAnd if your app is behind a proxy or load balancer (common with DigitalOcean Load Balancers), add this to your App\\Http\\Middleware\\TrustProxies middleware or use Laravel's built-in trusted proxies configuration:
# In config/trustedproxy.php or TrustProxies middleware
\nprotected $proxies = '*';Without this, request()->secure() returns false even over HTTPS, which can cause mixed content warnings.
13. Configure Laravel Queue Workers & Scheduler
If your Laravel app sends emails, processes images, or does any background work, you're using queues. Queue workers are long-running processes that need to survive server reboots and restart automatically if they crash. Supervisor is the standard tool for this.
Install Supervisor
sudo apt install supervisor -yConfigure a Queue Worker
sudo nano /etc/supervisor/conf.d/laravel-worker.conf[program:laravel-worker]
\nprocess_name=%(program_name)s_%(process_num)02d
\ncommand=php /var/www/yourdomain.com/artisan queue:work --sleep=3 --tries=3 --max-time=3600
\nautostart=true
\nautorestart=true
\nstopasgroup=true
\nkillasgroup=true
\nuser=www-data
\nnumprocs=2
\nredirect_stderr=true
\nstdout_logfile=/var/www/yourdomain.com/storage/logs/worker.log
\nstopwaitsecs=3600Key settings explained:
numprocs=2— Runs 2 worker processes. Increase for high-volume queues.--max-time=3600— Worker exits cleanly after 1 hour, preventing memory bloat on long-running processes. Supervisor restarts it immediately.--tries=3— Failed jobs are retried 3 times before being marked as failed.autorestart=true— If the worker crashes, Supervisor restarts it automatically.
Load and start the worker:
sudo supervisorctl reread
\nsudo supervisorctl update
\nsudo supervisorctl start laravel-worker:*
\n# Check status
\nsudo supervisorctl statusConfigure Laravel Scheduler
Laravel's task scheduler runs via a single cron entry. Add it with:
sudo crontab -e -u www-dataAdd this line:
* * * * * cd /var/www/yourdomain.com && php artisan schedule:run >> /dev/null 2>&1This runs every minute and Laravel's scheduler internally handles which tasks should actually execute based on their defined frequency.
14. Final Checks & Performance Optimizations
Verify Your Deployment
# Check PHP-FPM is running
\nsudo systemctl status php8.2-fpm
\n
\n# Check Nginx is running
\nsudo systemctl status nginx
\n
\n# Check MySQL is running
\nsudo systemctl status mysql
\n
\n# Check Supervisor workers are running
\nsudo supervisorctl status
\n
\n# Check fail2ban is running
\nsudo systemctl status fail2ban
\n
\n# View your UFW rules
\nsudo ufw status verboseTest Your Application
# Test Laravel can connect to the database
\nphp artisan tinker# Inside Tinker: \n# DB::connection()->getPdo(); \n# Exit with Ctrl+C\n \n
# Check for any configuration errors
\nphp artisan aboutSet Up Log Rotation
Laravel logs can grow large in production. Set up log rotation so they don't fill your disk:
sudo nano /etc/logrotate.d/laravel/var/www/yourdomain.com/storage/logs/*.log {
\n daily
\n missingok
\n rotate 14
\n compress
\n notifempty
\n create 0664 www-data www-data
\n sharedscripts
\n}Check Server Response Time
After all optimizations, test your server response time:
[On Linux terminal run:]
\ncurl -o /dev/null -s -w "Time to first byte: %{time_starttransfer}s\\n" https://yourdomain.com[On windows powershell run:]\ncurl.exe -o null -s -w "Time to first byte: %{time_starttransfer}s\\n" https://yourdomain.com
With OPcache, config caching, and route caching enabled, you should see times well under 300ms on a properly sized VPS.
Monitor Your Server
A few useful commands to keep in your toolkit:
# Real-time server resource usage
\nhtop
\n
\n# Check disk usage
\ndf -h
\n
\n# Check memory usage
\nfree -h
\n
\n# Watch Nginx access logs in real-time
\nsudo tail -f /var/log/nginx/yourdomain.com_access.log
\n
\n# Watch Laravel logs in real-time
\ntail -f /var/www/yourdomain.com/storage/logs/laravel.log
\n
\n# Check fail2ban banned IPs
\nsudo fail2ban-client status sshdFurther reading:
- Laravel Official Deployment Documentation
- Nginx Official Documentation
- DigitalOcean Droplet Documentation
- DigitalOcean — Laravel on LEMP Stack Guide
Wrapping Up
At this point, your Laravel application is running on a production VPS with a hardened server configuration: non-root SSH access, a custom SSH port, disabled ping responses, fail2ban monitoring, a locked-down firewall, properly configured Nginx or Apache virtual host, free SSL, supervised queue workers, and full caching optimization.
This isn't a "good enough for now" setup — this is production-grade infrastructure. The difference between this and a basic upload-and-hope deployment is the difference between a server that gets compromised in a week and one that runs reliably for years.
If any step gave you trouble, or you'd rather have someone handle this deployment professionally — that's exactly what I do. Check out my Laravel & MERN Stack Deployment Services or hire me directly through my Upwork profile for a fast, fully documented deployment.