Production server setup checklist: everything to do before you take traffic
A production server is ready for traffic when you log in as a non-root sudo user with an SSH key (passwords and root login disabled), the firewall denies inbound by default and allows only 22, 80 and 443, security updates install themselves, TLS is live with automatic renewal, and the database listens on localhost with a per app user. It also needs supervised queue workers and cron, log rotation, swap, an external uptime check, and disk and memory alerts. Backups only count once you have restored one, and a release layout only counts once you have rolled back on purpose and watched it work. Budget two to four hours by hand for the first server, or automate it from a recipe so server number ten is identical to server number one.
Skip the manual setup. DeployManage provisions your server and ships zero-downtime deploys on any cloud.
$ get started freeA production server is ready for traffic when eight things are true: you log in as a non-root sudo user with an SSH key and password auth is off, the firewall denies inbound by default and permits only 22, 80 and 443, security patches install without you, TLS is live and renews itself, the database listens on localhost with a per app user, a backup has actually been restored, workers and cron are supervised by something that restarts them, and a service outside the box tells you when the site is down. The rest of this page is the long version of that sentence, with the commands. Work top to bottom on a fresh Ubuntu LTS box and you will end up with a server you can defend. If you would rather not do it by hand every time, DeployManage provisions hardened servers on Hetzner, DigitalOcean, AWS, Vultr, Linode, OVHcloud and custom VPS with this hardening applied from reusable recipes, and it has a free plan to start.
The checklist at a glance
| Area | What to do | Why it matters |
| Access | Non-root sudo user, ed25519 key, PermitRootLogin no, PasswordAuthentication no | Kills credential stuffing and 99% of the bot traffic hammering port 22 |
| Firewall | ufw default deny inbound, allow 22, 80, 443 only, plus a provider level firewall | Databases, Redis and app ports must never be reachable from the internet |
| Patching | unattended-upgrades for security origins, reboot window for kernels | Most breaches use a known CVE with a patch already available |
| Web server | Nginx plus PHP-FPM or a systemd unit for Node, app runs as an unprivileged user | A compromised app should not own the box |
| Permissions | Code deploy:www-data, dirs 755, files 644, writable paths 775, never 777 | Stops one site reading another site's secrets |
| TLS | Let's Encrypt, 301 from port 80, HSTS, renewal timer tested with a dry run | An expired certificate is a full outage that you scheduled 90 days ago |
| Database | Bind to 127.0.0.1, one user per app, scoped grants, long random password | Limits blast radius when one app leaks its .env |
| Backups | Nightly encrypted dump offsite, plus a restore you have actually performed | An untested backup is a hypothesis, not a backup |
| Kernel and limits | Swap file, vm.swappiness=10, raised LimitNOFILE, UTC, NTP synced | Prevents the OOM killer and file descriptor exhaustion at peak |
| Logs and disk | logrotate for app logs, capped journald, alert at 80% disk | A full disk takes down Nginx, the database and your deploys at once |
| Monitoring | External uptime check, CPU, memory, disk and 5xx alerts, certificate expiry watch | You should hear it from a pager, not from a customer |
| Background jobs | systemd or Supervisor for queue workers, one cron line for the scheduler | Workers die. Something has to notice and restart them |
| Releases | releases/, shared/, a current symlink, keep the last 5 | Atomic switch forward, atomic switch back |
| Secrets | .env in shared/, chmod 640, never committed, rotated when people leave | Git history is forever, and it is the first place an attacker looks |
How do I set up a production server?
Set up a production server in this order: create a non-root sudo user with an SSH key, disable root login and password auth, enable a default deny firewall, turn on automatic security updates, install Nginx and your app runtime, issue a TLS certificate, lock the database to localhost, then add backups, monitoring, supervised workers and a release layout that can roll back. Order matters because each step assumes the one before it.
Do the access work before anything is listening on port 80. A fresh VPS with a public IP starts getting SSH probes within minutes, so the first 20 minutes are the ones that count.
Step 1: a non-root sudo user and key only SSH
Nobody should be doing daily work as root. Create a user, grant sudo, copy your key up:
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
Open a second terminal and confirm ssh deploy@your-server works with the key before you touch the SSH daemon. Then set these in /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
X11Forwarding no
Validate the file with sshd -t, reload with systemctl reload ssh, and keep your current session open until a brand new login succeeds. One gotcha that bites people: on recent Ubuntu images, drop in files under /etc/ssh/sshd_config.d/ are read after the main config, and cloud images often ship one that re-enables password auth. Grep that directory. Use ssh-keygen -t ed25519 for new keys. Moving SSH to a non-standard port is cosmetic, it reduces log noise, not risk.
Step 2: default deny firewall plus fail2ban
Three ports. That is the entire public surface of a normal web server.
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
MySQL on 3306, Postgres on 5432, Redis on 6379 and your Node port on 3000 stay private, always. Add a firewall at the provider level too (a Hetzner firewall, an AWS security group, a DigitalOcean cloud firewall) so a fat fingered ufw disable is not the only thing between your database and the internet. Then install fail2ban and give it a jail in /etc/fail2ban/jail.local:
[sshd]
enabled = true
maxretry = 4
findtime = 10m
bantime = 1h
With password auth disabled, fail2ban is mostly log hygiene on SSH, but it earns its place the moment you expose a login form or an admin panel, where an nginx-limit-req or nginx-http-auth jail does real work.
Step 3: automatic security updates
Install unattended-upgrades, restrict it to security origins, and let it run nightly:
apt install unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
Unattended upgrades will not reboot into a new kernel unless you tell it to. Either set Unattended-Upgrade::Automatic-Reboot-Time "04:00" in /etc/apt/apt.conf.d/50unattended-upgrades, or check /var/run/reboot-required on a schedule and reboot during a window you choose. Pick one. A server that has been up for 400 days is not a badge of honor, it is an unpatched kernel.
How do I secure a Linux server?
Secure a Linux server by taking the easy wins first: disable root SSH login and password authentication, use key based auth with a non-root sudo user, run a default deny firewall exposing only 22, 80 and 443, enable unattended security updates, bind the database to localhost with a least privilege user, and keep secrets out of git behind strict file permissions.
After that it is about surface area. Run ss -tulpn and account for every listening socket. Uninstall anything you are not using. Run the app as its own unprivileged user, never root. Audit sudoers for NOPASSWD lines you forgot about. Run lynis audit system once and read the warnings. Leave AppArmor enabled instead of disabling it because a tutorial told you to.
Step 4: Nginx, the app runtime, and correct permissions
Nginx terminates TLS and proxies to PHP-FPM over a unix socket, or to Node on 127.0.0.1:3000. Set server_tokens off;, a sane client_max_body_size, and gzip. For Node, write a real systemd unit with Restart=always, User=deploy and an explicit LimitNOFILE, rather than leaving a process in a tmux session and hoping.
Ownership is where most people quietly break things. Code should be owned by the deploy user and group readable by the web server, with writable paths (Laravel's storage and bootstrap/cache, or your uploads directory) group writable and nothing else:
chown -R deploy:www-data /var/www/app
find /var/www/app -type d -exec chmod 755 {} \;
find /var/www/app -type f -exec chmod 644 {} \;
chmod -R 775 /var/www/app/shared/storage
If you type chmod -R 777 to fix a permissions error, you have not fixed it, you have made it exploitable. On a box hosting several sites, give each site its own PHP-FPM pool running as its own user, so a compromise in one cannot read the other's .env.
Step 5: TLS, the HTTPS redirect and HSTS
Issue the certificate with certbot and let it wire up Nginx:
certbot --nginx -d example.com -d www.example.com
certbot renew --dry-run
The dry run is the important half. Renewal runs from a systemd timer (systemctl list-timers | grep certbot), and the classic failure is a renewal that succeeds but never reloads Nginx, so the old certificate stays in memory until it expires. Add --deploy-hook "systemctl reload nginx". Force HTTPS with a 301 and turn on HSTS once you are confident every subdomain is served over TLS:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Restrict to TLS 1.2 and 1.3. Only add preload to the HSTS header when you are certain, because it is very hard to undo.
Step 6: database, credentials and backups you have restored
Set bind-address = 127.0.0.1 for MySQL or listen_addresses = 'localhost' for Postgres, run mysql_secure_installation, and create one user per application with grants scoped to that application's schema. No GRANT ALL ON *.*. Generate a long random password and put it in the app's .env, nowhere else.
Then backups. Nightly logical dump, encrypted, pushed to object storage in a different provider or at least a different region, with a retention policy (say 7 daily, 4 weekly, 6 monthly). A VM snapshot is not an application consistent database backup and should not be your only copy. Now the part almost everyone skips: restore one. Pull last night's dump into a scratch database, run row counts against production, boot the app against it. Put a recurring calendar event on it every quarter. A backup you have never restored is a hypothesis.
Step 7: swap, sysctl, limits and time
Add swap even on a box with plenty of RAM, because the difference between a slow minute and the OOM killer eating your database process is often a 2GB file:
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
Set vm.swappiness=10 so it is an emergency valve, not a habit. Raise file descriptor limits for Nginx, PHP-FPM and Node (LimitNOFILE=65535 in the systemd unit, not just ulimit -n in a shell that nothing inherits). Bump net.core.somaxconn if you expect real concurrency. Keep the server in UTC (timedatectl set-timezone UTC) and confirm NTP is syncing with timedatectl status, since skewed clocks break TLS handshakes, TOTP logins and every log correlation you will ever attempt.
Step 8: log rotation and disk headroom
A full disk is the most common self inflicted outage there is. Nginx stops writing, the database refuses writes, deploys fail, and the fix at 3am is always du -sh /*. Give every app log a logrotate config (daily, rotate 14, compress, notifempty), cap journald with SystemMaxUse=500M in /etc/systemd/journald.conf, and alert at 80% used rather than 95%. Prune old Docker images and old releases on a schedule if you use them.
Step 9: monitoring you will actually notice
Agent based monitoring on the server can report CPU, memory, disk, load and 5xx rate, and it should page you on all of them. But an agent on a dead box reports nothing, so pair it with an external uptime check that hits the site every 30 seconds from outside your network and notifies you after two consecutive failures. Watch certificate expiry as its own check. Then test the alerting: stop Nginx and see whether the page actually arrives on your phone. An alert nobody receives is worse than no alert, because it feels like coverage.
Step 10: supervised workers and the scheduler
Queue workers are long lived processes that will die eventually, so they need a supervisor. A systemd unit with Restart=always, RestartSec=5, User=deploy and a templated name for multiple copies does the job, and Supervisor works fine if that is your habit. Run the worker with limits so it recycles itself: php artisan queue:work --tries=3 --max-time=3600. Remember that PHP workers hold your old code in memory after a deploy, so your deploy script must call php artisan queue:restart or the new release will not take effect in the queue.
The scheduler is one cron line, owned by the deploy user, never root:
* * * * * cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1
Step 11: a directory layout that can roll back
Deploy into timestamped directories and flip a symlink. Nothing else gives you an instant rollback:
/var/www/app/releases/20260714101500/
/var/www/app/shared/.env
/var/www/app/shared/storage/
/var/www/app/current -> releases/20260714101500
Nginx points at current/public. You build the new release fully (composer install, npm build, migrations), then swap the symlink and reload PHP-FPM so OPcache stops serving the old path. Rollback is repointing current at the previous release and reloading, which takes about a second. Keep the last five releases, prune the rest. The mechanics and the migration edge cases are covered in zero downtime deployment explained, and there is a full walkthrough for PHP apps in how to deploy a Laravel app to a VPS.
Step 12: secrets and .env handling
The .env lives in shared/ and is symlinked into each release, so it survives deploys and never sits in git. Set it to chmod 640, owner deploy, group www-data. Confirm APP_DEBUG=false and APP_ENV=production before you point DNS, because a debug stack trace on a public 500 page will happily print your database password. If a secret has ever been committed, deleting the file does not help, git history keeps it. Rotate the credential instead. Rotate on staff changes too.
What is server hardening?
Server hardening is the practice of shrinking a server's attack surface and then defending what is left: removing packages, ports, accounts and permissions the machine does not need, and enabling the controls it does need, such as key only SSH, a default deny firewall, automatic patching, least privilege database users, and file permissions that stop one app from reading another app's secrets.
It is a state you maintain rather than a task you finish. Servers drift: someone opens a port to debug something, a package gets installed by hand, a permission gets loosened at 2am. This is exactly why hardening belongs in code (a cloud-init template, an Ansible role, or a provisioning recipe) instead of in your memory of what you typed last time.
How long does it take to set up a production server?
By hand, a careful first pass on a fresh VPS takes two to four hours if you already know the steps, and closer to a full day if you are also debugging Nginx, TLS, permissions and PHP-FPM pools for the first time. Provisioned from an automated recipe, the same hardened server is ready in five to ten minutes.
The number that actually matters is the second server, and the tenth. Doing this by hand once is educational. Doing it eleven times guarantees that eleven servers are subtly different, which is how a rule you set on server one goes missing on server nine and nobody notices until it matters.
Do I need a control panel to manage a server?
No. You can run production on a plain VPS with systemd, Nginx and a deploy script, and plenty of good teams do exactly that. A panel starts paying for itself when you have more than one or two servers, more than one person deploying, or a need to show who changed what and when.
That is the gap DeployManage fills. It provisions hardened servers on Hetzner, DigitalOcean, AWS, Vultr, Linode, OVHcloud and custom VPS with the hardening in this checklist applied from reusable recipes, then runs zero downtime deploys with rollback, managed MySQL and PostgreSQL, SSL, firewall rules, queue workers and scheduled jobs, with a fleet wide audit log of every change. There is a free plan to start. Panel or playbook, the principle is the same: no production server should be hand built and undocumented.
The final pass before you point DNS
Reboot the box. Everything that should come back must come back on its own: systemctl is-enabled nginx php8.3-fpm mysql fail2ban and your worker units. Then try to SSH as root (should fail), try password auth (should fail), scan yourself from outside and confirm only 22, 80 and 443 answer. Curl the site over HTTP and check for the 301. Restore last night's database dump into a scratch database. Roll back to the previous release, then roll forward again. Stop Nginx and wait for your phone to buzz.
If every one of those behaves the way you expect, the server is ready for traffic. If any of them surprises you, you just found the incident you would otherwise have had at 3am on a Saturday.
Frequently asked questions
What ports should be open on a production server?
Only 22 (SSH), 80 (HTTP) and 443 (HTTPS), with everything else denied inbound by default. Databases, Redis, memcached and your Node or application port must stay bound to 127.0.0.1. If you need remote database access, tunnel it over SSH instead of opening 3306 or 5432 to the internet.
Should I disable root SSH login?
Yes. Set PermitRootLogin no in /etc/ssh/sshd_config and log in as a non-root user with sudo. Root is the one username every bot already knows, so leaving it enabled gives attackers half the credential for free. Sudo also gives you an audit trail of who ran what, which root does not.
How often should I patch a production server?
Security patches should install automatically every night via unattended-upgrades. Kernel and libc updates need a reboot, so schedule a monthly reboot window, or reboot sooner when /var/run/reboot-required appears after a high severity CVE. Anything you defer indefinitely turns into the vulnerability that gets you.
Do I still need fail2ban if password authentication is disabled?
It is optional for SSH, since key only auth already blocks brute force, but it is still useful. Fail2ban keeps your auth logs readable by banning noisy scanners, and its Nginx jails protect application level logins, admin panels and API endpoints from credential stuffing and request floods, which key based SSH does nothing about.