This guide takes a fresh Ubuntu 22.04 VPS on DigitalOcean or Hostinger and ends with a Laravel app running in production: key-only SSH, fail2ban, a locked-down UFW firewall, Nginx (or Apache), free SSL, supervised queue workers and Laravel's caches warmed up. Every command is here, in the order I run them.
I wrote it as the guide I wish I'd had when I first set up production servers. Along the way I explain why each security step matters, so you understand your server instead of pasting commands blindly. The commands are nearly identical on DigitalOcean Droplets and Hostinger VPS plans.
If you'd rather not do it yourself, I also offer Laravel server deployment as a service.
Table of Contents
- Prerequisites
- Initial Server Access
- Create a Non-Root Sudo User
- Change the Default SSH Port (and Why It Matters)
- Disable Ping on Your Server IP (and Why You Should)
- Install and Configure fail2ban
- Configure the UFW Firewall
- Install the LEMP Stack (Nginx, PHP and MySQL)
- Configure an Nginx Virtual Host for Laravel
- Alternative: Apache Virtual Host for Laravel
- Deploy Your Laravel Application
- Install an SSL Certificate with Let's Encrypt
- Configure Laravel Queue Workers and the Scheduler
- Final Checks and Performance Tuning
1. Prerequisites
Have these ready before you start:
- A fresh Ubuntu 22.04 LTS VPS on DigitalOcean or Hostinger. I'd use at least 1 GB of RAM for Laravel.
- Your domain pointed at the server's IP address, with the A record set in your DNS.
- Your Laravel project in a Git repository (GitHub, GitLab or Bitbucket).
- SSH access. Both providers hand you root credentials when the server is first created.
- A local terminal: Terminal on Mac or Linux, or WSL or PuTTY on Windows.
A note on versions: everything here uses Ubuntu 22.04 LTS (Jammy Jellyfish), and the commands also work on Ubuntu 20.04. On CentOS or Debian the package manager commands differ slightly.
2. Initial Server Access
When the VPS is created you get a root password (Hostinger) or you use the SSH key you added (DigitalOcean). Connect for the first time:
ssh root@YOUR_SERVER_IP
With a DigitalOcean SSH key, point SSH at the key file:
ssh -i ~/.ssh/your_key root@YOUR_SERVER_IP
The first job on a new server is updating every package. Don't skip it:
apt update && apt upgrade -y
That refreshes the package list and upgrades what's installed, security patches included. Running a server with outdated packages is like leaving the front door unlocked.
3. Create a Non-Root Sudo User
Working as root all day is risky. One typo, such as rm -rf /, can wipe the whole server with no warning and no confirmation. So create a separate user with sudo rights and turn off direct root login.
# Create a new user (replace 'zeeshan' with your username)
adduser zeeshan
# Add the user to the sudo group
usermod -aG sudo zeeshan
Copy your SSH keys over so the new user can log in:
# While still logged in as root
rsync --archive --chown=zeeshan:zeeshan ~/.ssh /home/zeeshan
Open a second terminal window and log in as the new user before you close the root session:
ssh zeeshan@YOUR_SERVER_IP
Check that sudo works:
sudo whoami
# Should output: root
Now disable root login over SSH. Open the SSH config:
sudo nano /etc/ssh/sshd_config
Find these lines and change them:
PermitRootLogin no
PasswordAuthentication no
With PasswordAuthentication no, only people holding an SSH key can log in, so nobody can brute-force a password. Save the file (Ctrl+X, then Y, then Enter) and restart SSH:
sudo systemctl restart sshd
4. Change the Default SSH Port (and Why It Matters)
SSH listens on port 22 by default, and everyone knows it. Within minutes of a server going online, bots from all over the world start hammering port 22 with common usernames and passwords. It isn't theoretical. Check the auth log on any fresh server after about ten minutes:
sudo cat /var/log/auth.log | grep "Failed password"
You'll see hundreds of failed attempts from IPs everywhere. A different SSH port won't make you invincible, but most automated attacks scan port 22 specifically, so moving it removes the bulk of them. It's a small change that shrinks your attack surface right away.
How to Change the SSH Port
Open the SSH daemon config again:
sudo nano /etc/ssh/sshd_config
Find this line:
#Port 22
Uncomment it and set a port between 1024 and 65535. Pick something non-obvious, and skip popular alternatives like 2222 because bots scan those too:
Port 4827
Before restarting SSH, allow the new port in UFW, or you'll lock yourself out:
sudo ufw allow 4827/tcp
Now restart SSH:
sudo systemctl restart sshd
Open a new terminal and test the new port before you close the current session:
ssh -p 4827 zeeshan@YOUR_SERVER_IP
Once that works, close the old session. From now on connect with -p 4827 (or whichever port you chose). To avoid typing it every time, add the server to your SSH config on your own machine:
# On your LOCAL machine, edit ~/.ssh/config
Host myserver
HostName YOUR_SERVER_IP
User zeeshan
Port 4827
IdentityFile ~/.ssh/your_key
After that, ssh myserver is all you need.
Further reading: SSH Academy on changing the SSH port.
5. Disable Ping on Your Server IP (and Why You Should)
When someone pings your IP and gets a reply, they learn two things: the address is live and a server is running there. That's the first step of most network reconnaissance, and bots run ping sweeps across whole IP ranges to find live servers. If you drop ICMP echo requests, your server stays quiet to those scans.
It won't stop someone who already knows your IP and is determined. It does cut your exposure to opportunistic scanners, and a server that doesn't need to answer public diagnostic pings has no reason to keep replying.
How to Block Ping with UFW
Edit UFW's before.rules file:
sudo nano /etc/ufw/before.rules
Near the top, find this section:
# ok icmp codes for INPUT
-A ufw-before-input -p icmp --icmp-type destination-unreachable -j ACCEPT
-A ufw-before-input -p icmp --icmp-type time-exceeded -j ACCEPT
-A ufw-before-input -p icmp --icmp-type parameter-problem -j ACCEPT
-A ufw-before-input -p icmp --icmp-type echo-request -j ACCEPT
Change the last line from ACCEPT to DROP:
-A ufw-before-input -p icmp --icmp-type echo-request -j DROP
Save the file and reload UFW:
sudo ufw reload
Test from your local machine:
ping YOUR_SERVER_IP
# Should now show: Request timeout
Leave destination-unreachable and time-exceeded on ACCEPT. TCP/IP routing and MTU discovery need them. Only the echo request gets dropped.
6. Install and Configure fail2ban
A custom SSH port and key-only login already do a lot, but I still run fail2ban. It watches your log files in real time and bans IP addresses that behave badly, such as repeated failed SSH logins or a flood of 404 errors on your web server.
Think of it as an automated bouncer. After a set number of failures inside a time window, fail2ban adds an iptables rule that blocks that IP for a while. That protects SSH, and also your web app, mail and any other service you point it at.
Install fail2ban
sudo apt install fail2ban -y
Configure fail2ban
Don't edit /etc/fail2ban/jail.conf directly, because updates overwrite it. Make a local copy instead:
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
Find the [DEFAULT] section and set these values:
[DEFAULT]
# Ban an IP for 1 hour (in seconds)
bantime = 3600
# Look for failures within a 10-minute window
findtime = 600
# Ban after 5 failed attempts
maxretry = 5
# Your own IP to never ban (replace with your home/office IP)
ignoreip = 127.0.0.1/8 ::1 YOUR_HOME_IP
Then find the [sshd] section and enable it with your custom port:
[sshd]
enabled = true
port = 4827
logpath = %(sshd_log)s
backend = %(sshd_backend)s
To have fail2ban guard Nginx against brute-force attempts too:
[nginx-http-auth]
enabled = true
[nginx-limit-req]
enabled = true
logpath = /var/log/nginx/error.log
Start it and enable it at boot:
sudo systemctl start fail2ban
sudo systemctl enable fail2ban
Check the status and the active jails:
sudo fail2ban-client status
sudo fail2ban-client status sshd
If you ever ban yourself by accident, unban your IP like this:
sudo fail2ban-client set sshd unbanip THE_IP_ADDRESS
Further reading: the fail2ban manual.
7. Configure the UFW Firewall
UFW (Uncomplicated Firewall) is Ubuntu's standard firewall tool. The rule is simple: deny everything by default, then allow only what you need.
# Set default rules
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow your custom SSH port (use whatever port you chose in Step 4)
sudo ufw allow 4827/tcp
# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Enable the firewall
sudo ufw enable
# Check status
sudo ufw status verbose
You should see only ports 4827, 80 and 443 open, and nothing else. If an outside service needs to reach MySQL, open port 3306 for one trusted IP only:
sudo ufw allow from TRUSTED_IP to any port 3306
Never open port 3306 to the whole internet. It's a common mistake and a serious one.
8. Install the LEMP Stack (Nginx, PHP and MySQL)
LEMP stands for Linux, Nginx, MySQL (or MariaDB) and PHP. It's the standard stack for deploying Laravel on a VPS.
Install Nginx
sudo apt install nginx -y
sudo systemctl start nginx
sudo systemctl enable nginx
Install PHP and the Required Extensions
Laravel 10 and newer need PHP 8.1 or higher. I'll install PHP 8.2 with the extensions Laravel uses:
# Add the Ondrej PHP PPA for current PHP versions
sudo apt install software-properties-common -y
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
# Install PHP 8.2 and Laravel's required extensions
sudo apt install php8.2 php8.2-fpm php8.2-mysql php8.2-mbstring php8.2-xml \
php8.2-bcmath php8.2-curl php8.2-zip php8.2-gd php8.2-intl php8.2-redis \
php8.2-tokenizer php8.2-fileinfo -y
Confirm PHP is installed:
php -v
Turn on OPcache next. It keeps compiled PHP in memory, so PHP runs noticeably faster. Open the FPM config:
sudo nano /etc/php/8.2/fpm/php.ini
Find these OPcache settings and set them:
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=10000
opcache.revalidate_freq=2
opcache.fast_shutdown=1
Install MySQL
sudo apt install mysql-server -y
sudo mysql_secure_installation
The secure installation script asks you to set a root password, remove anonymous users, disable remote root login and drop the test database. Answer yes to all of them.
Then create a database and a user for your Laravel app:
sudo mysql -u root -p
-- Inside MySQL shell
CREATE DATABASE your_app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'your_app_user'@'localhost' IDENTIFIED BY 'StrongPassword123!';
GRANT ALL PRIVILEGES ON your_app_db.* TO 'your_app_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Install Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
composer --version
Install Git
sudo apt install git -y
9. Configure an Nginx Virtual Host for Laravel
This is one of the most important steps in a Laravel deployment. A correct Nginx server block points the document root at Laravel's public/ directory, hands PHP to PHP-FPM, and sends every request through index.php. The root matters most: if you point it at the project folder instead, your .env and source code are exposed to the web.
If you see a 403 Forbidden or a 404 right after setup, check the root line and the try_files rule first.
Create a server block for your domain:
sudo nano /etc/nginx/sites-available/yourdomain.com
Paste in this complete configuration:
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
# IMPORTANT: the document root points to Laravel's public/ directory
# Never point this to your project root
root /var/www/yourdomain.com/public;
index index.php index.html;
# Security headers: protect against common web attacks
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# Hide the Nginx version from response headers
server_tokens off;
# Maximum upload size: match your php.ini setting
client_max_body_size 64M;
# Laravel's front controller pattern
# All requests that don't match a real file go to index.php
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Block access to hidden files (like .env, .git)
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# PHP-FPM configuration
# A Unix socket is faster than TCP (127.0.0.1:9000)
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
# Pass the real IP to PHP (useful for logging and rate limiting)
fastcgi_param REMOTE_ADDR $remote_addr;
}
# Cache static assets aggressively
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Don't log favicon or robots.txt requests
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_log /var/log/nginx/yourdomain.com_error.log;
access_log /var/log/nginx/yourdomain.com_access.log;
}
Why These Configuration Choices Matter
root /var/www/.../public: pointing atpublic/keeps your.envfile, application code andvendor/directory out of reach from the web. This is basic Laravel security.- Security headers:
X-Frame-Optionsprevents clickjacking andX-Content-Type-Optionsstops MIME sniffing. They cost nothing and protect your users. - Unix socket instead of TCP:
unix:/var/run/php/php8.2-fpm.sockis faster than127.0.0.1:9000because it skips the TCP stack. Use the socket whenever Nginx and PHP-FPM share a server, which is almost always. location ~ /\.: this blocks every hidden file and folder. Without it, someone could openyourdomain.com/.envdirectly.
Enable the site with a symlink and test the configuration:
# Enable the site
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
# Test the Nginx config for syntax errors: always do this before reloading
sudo nginx -t
# If the test passes, reload Nginx
sudo systemctl reload nginx
Always run sudo nginx -t before a reload. Reloading a broken config can take your server down.
10. Alternative: Apache Virtual Host for Laravel
If your environment runs Apache (common on cPanel and some Hostinger plans), here's the virtual host setup. The main difference from Nginx is that Apache uses .htaccess files for URL rewriting, and Laravel already ships one in public/.
Enable the Apache modules you need:
sudo a2enmod rewrite
sudo a2enmod headers
sudo a2enmod ssl
sudo systemctl restart apache2
Install PHP for Apache:
sudo apt install libapache2-mod-php8.2 -y
Create the virtual host file:
sudo nano /etc/apache2/sites-available/yourdomain.com.conf
<VirtualHost *:80>
ServerName yourdomain.com
ServerAlias www.yourdomain.com
# Document root points to Laravel's public/ directory
DocumentRoot /var/www/yourdomain.com/public
# Security: hide the Apache version
ServerTokens Prod
ServerSignature Off
# Security headers
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
<Directory /var/www/yourdomain.com/public>
# AllowOverride All is required for Laravel's .htaccess to work
# Without it, URL rewriting breaks and you get 404 errors
AllowOverride All
Require all granted
Options -Indexes -MultiViews
</Directory>
# Block access to sensitive files
<FilesMatch "^\.env|composer\.(json|lock)$">
Require all denied
</FilesMatch>
ErrorLog ${APACHE_LOG_DIR}/yourdomain.com_error.log
CustomLog ${APACHE_LOG_DIR}/yourdomain.com_access.log combined
</VirtualHost>
Why AllowOverride All Matters for Laravel on Apache
Laravel's public/.htaccess sends every request to index.php so the router can handle it. If AllowOverride All is missing, Apache ignores that file completely, and every route except the homepage returns a 404. It's the most common Laravel and Apache misconfiguration I see.
Enable the site:
sudo a2ensite yourdomain.com.conf
# Disable the default site if you haven't already
sudo a2dissite 000-default.conf
# Test the Apache config
sudo apache2ctl configtest
# Reload Apache
sudo systemctl reload apache2
11. Deploy Your Laravel Application
Now the app itself. Create the web directory and clone your repository:
# Create the directory for your app
sudo mkdir -p /var/www/yourdomain.com
# Give your user ownership
sudo chown -R $USER:$USER /var/www/yourdomain.com
# Clone your repository
cd /var/www
git clone https://github.com/yourusername/your-repo.git yourdomain.com
cd yourdomain.com
Install Composer Dependencies
# --no-dev skips development dependencies (testing tools, debugbars, etc.)
# --optimize-autoloader generates a faster class autoloader for production
composer install --optimize-autoloader --no-dev
Set Up the Environment File
cp .env.example .env
nano .env
Set these values in your .env:
APP_NAME="Your App Name"
APP_ENV=production
APP_KEY= # Leave blank, generate below
APP_DEBUG=false # CRITICAL: always false in production
APP_URL=https://yourdomain.com
LOG_CHANNEL=stack
LOG_LEVEL=error # Only log errors in production, not debug info
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_app_db
DB_USERNAME=your_app_user
DB_PASSWORD=StrongPassword123!
CACHE_DRIVER=file # Change to redis if you have Redis installed
QUEUE_CONNECTION=database # Or redis for better performance
SESSION_DRIVER=file
Generate the application key:
php artisan key:generate
Set the Correct File Permissions
This is where a lot of deployments break. The web server runs as www-data on Ubuntu, and it needs write access to storage/ and bootstrap/cache/. Nothing else should be writable by it:
# Set ownership: your user owns the files, www-data is the group
sudo chown -R $USER:www-data /var/www/yourdomain.com
# Directories need execute permission to be traversable
sudo find /var/www/yourdomain.com -type d -exec chmod 755 {} \;
# Files should be readable but not executable
sudo find /var/www/yourdomain.com -type f -exec chmod 644 {} \;
# Storage and cache must be writable by www-data
sudo chmod -R 775 /var/www/yourdomain.com/storage
sudo chmod -R 775 /var/www/yourdomain.com/bootstrap/cache
Run the Database Migrations
php artisan migrate --force
Laravel asks for confirmation before migrating in production, and --force skips that prompt. Only use it when you're sure about the migrations you're running.
Seed the Database (if needed)
php artisan db:seed --force
Laravel Performance Optimizations
Run all of these on every deployment. They cache your routes, config and views so Laravel doesn't parse them again on each request:
# Cache configuration: reads .env once and caches the result
php artisan config:cache
# Cache routes: pre-compiles routes/web.php and routes/api.php
php artisan route:cache
# Cache views: pre-compiles Blade templates
php artisan view:cache
# Generate an optimized autoloader
composer dump-autoload --optimize
One thing to remember: after config:cache, Laravel stops reading your .env file. Whenever you change a value there, run php artisan config:cache again, or clear the cache first with php artisan config:clear.
Create the storage symlink so uploaded files can be served publicly:
php artisan storage:link
12. Install an SSL Certificate with Let's Encrypt
There's no good reason to run a production app without HTTPS today. Let's Encrypt gives you free certificates with automatic renewal, and Google also favors HTTPS sites in search.
# Install Certbot and the Nginx plugin
sudo apt install certbot python3-certbot-nginx -y
# Obtain and automatically configure SSL for your domain
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Certbot asks for your email address (for renewal reminders) and whether to redirect HTTP to HTTPS. Choose option 2, Redirect. It then updates your Nginx config to serve HTTPS and redirect all HTTP traffic.
On Apache, use the Apache plugin instead:
sudo apt install python3-certbot-apache -y
sudo certbot --apache -d yourdomain.com -d www.yourdomain.com
Verify Auto-Renewal
Let's Encrypt certificates expire after 90 days. Certbot installs a systemd timer that renews them for you. Test it:
sudo certbot renew --dry-run
If the dry run succeeds, renewal is set up correctly.
Update the Laravel APP_URL
With HTTPS live, make sure your .env matches:
APP_URL=https://yourdomain.com
If your app sits behind a proxy or load balancer (common with DigitalOcean Load Balancers), trust the proxy. On older Laravel versions, add this to the App\Http\Middleware\TrustProxies middleware or the trusted proxies config:
# In config/trustedproxy.php or TrustProxies middleware
protected $proxies = '*';
On Laravel 11 and newer, set it in bootstrap/app.php with $middleware->trustProxies(at: '*'); instead. Without it, request()->secure() returns false even over HTTPS, and that can cause mixed content warnings.
13. Configure Laravel Queue Workers and the Scheduler
If your app sends email, processes images or does any other background work, you're using queues. Queue workers are long-running processes, so they have to survive reboots and restart on their own if they crash. Supervisor is the standard tool for that.
Install Supervisor
sudo apt install supervisor -y
Configure a Queue Worker
sudo nano /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/yourdomain.com/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/yourdomain.com/storage/logs/worker.log
stopwaitsecs=3600
What the key settings do:
numprocs=2runs two worker processes. Raise it if your queue is busy.--max-time=3600makes a worker exit cleanly after one hour, which stops memory from building up in long-running processes. Supervisor starts a new one right away.--tries=3retries a failed job three times before marking it as failed.autorestart=truemeans Supervisor restarts the worker if it crashes.
Load the config and start the workers:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*
# Check status
sudo supervisorctl status
Configure the Laravel Scheduler
Laravel's task scheduler runs from a single cron entry. Open the crontab for www-data:
sudo crontab -e -u www-data
Add this line:
* * * * * cd /var/www/yourdomain.com && php artisan schedule:run >> /dev/null 2>&1
Cron runs it every minute, and Laravel decides internally which scheduled tasks are due.
14. Final Checks and Performance Tuning
Verify Your Deployment
# Check PHP-FPM is running
sudo systemctl status php8.2-fpm
# Check Nginx is running
sudo systemctl status nginx
# Check MySQL is running
sudo systemctl status mysql
# Check Supervisor workers are running
sudo supervisorctl status
# Check fail2ban is running
sudo systemctl status fail2ban
# View your UFW rules
sudo ufw status verbose
Test Your Application
# Test that Laravel can connect to the database
php artisan tinker
# Inside Tinker:
# DB::connection()->getPdo();
# Exit with Ctrl+C
# Check for any configuration errors
php artisan about
Set Up Log Rotation
Laravel logs can grow large in production. Rotate them so they don't fill your disk:
sudo nano /etc/logrotate.d/laravel
/var/www/yourdomain.com/storage/logs/*.log {
daily
missingok
rotate 14
compress
notifempty
create 0664 www-data www-data
sharedscripts
}
Check Server Response Time
Once everything is in place, measure how fast the server answers:
# On a Linux terminal:
curl -o /dev/null -s -w "Time to first byte: %{time_starttransfer}s\n" https://yourdomain.com
# On Windows PowerShell:
curl.exe -o null -s -w "Time to first byte: %{time_starttransfer}s\n" https://yourdomain.com
With OPcache, config caching and route caching on, a properly sized VPS should answer in well under 300 ms.
Monitor Your Server
A few commands worth keeping handy:
# Real-time server resource usage
htop
# Check disk usage
df -h
# Check memory usage
free -h
# Watch Nginx access logs in real time
sudo tail -f /var/log/nginx/yourdomain.com_access.log
# Watch Laravel logs in real time
tail -f /var/www/yourdomain.com/storage/logs/laravel.log
# Check fail2ban banned IPs
sudo fail2ban-client status sshd
Further reading:
- Laravel deployment documentation
- Nginx documentation
- DigitalOcean Droplet documentation
- DigitalOcean's Laravel on LEMP guide
Wrapping Up
Your Laravel app is now running on a production VPS with a hardened setup: SSH for a non-root user only, a custom SSH port, no ping replies, fail2ban watching the logs, a tight firewall, an Nginx or Apache virtual host, free SSL, supervised queue workers and Laravel's caches in place.
That's a production-grade server, not a stopgap. A plain upload-and-hope deployment can get compromised within a week. A server set up like this can run reliably for years.
If a step gave you trouble, or you'd rather have someone else handle the deployment, that's the work I do. See my Laravel and MERN stack server deployment service, or hire me directly on Upwork for a fast, fully documented setup. If something breaks on a live server, my maintenance and bug fixing service covers that too.