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.
One more record set if this box will also send mail, whether that means password resets from the application or real mailboxes on your domains: SPF, DKIM, DMARC and the reverse DNS PTR entry belong in this same DNS pass. Gmail requires matching forward and reverse DNS plus SPF or DKIM before it will trust a new sending host, and correcting those after the first send costs weeks of reputation rather than minutes of work. The full requirement list is in our guide to running a self hosted mail server.
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.
Do I need a control panel on a production server?
No, and everything on this checklist can be done with SSH and a text editor. The reason most teams end up with a panel anyway is that the list above is not a one time job. Certificates expire every ninety days, the firewall needs a rule every time a service moves, unattended upgrades occasionally need a reboot scheduled, and the queue workers need restarting on every deploy. A panel is a way of making sure those things still happen in month eight, when the person who built the server has moved on to other work.
If you do add one, be deliberate about what it costs you. A self hosted control panel is another production service with its own uptime, upgrades and backups, and it wants real resources: a self hosted platform as a service such as Coolify asks for 2 CPU cores, 2 GB of RAM and 30 GB of disk before it deploys anything of yours. Run it on separate infrastructure from the servers it manages, or you will discover during your first incident that the tool you need to deploy the fix is on the box that is down. Our comparison of the self hosted PaaS options and the hosted alternatives covers where each model puts that burden.
How long does it take to set up a production server?
Working through this checklist by hand takes an experienced administrator roughly two to four hours for a single application server, and most of that is not typing. It is waiting on DNS propagation before a certificate can be issued, waiting on a first backup to complete so the restore can be tested, and reading the application's own requirements for PHP extensions or system packages.
The number that matters more is how long it takes the second time. If the whole build is in your head, it takes another two to four hours and it will differ in small ways that surface as bugs later. If it is in a script, an Ansible playbook or a panel that provisions from a known template, it takes minutes and it is identical. Write the build down the first time you do it, even if the write up is only a shell script with comments, because a server you cannot rebuild is a server you cannot safely patch.
Ready to stop managing servers by hand? DeployManage provisions, deploys and monitors your fleet from one dashboard.
$ get started freeFrequently 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.
Do I need a control panel on a production server?
No. Every step on this checklist can be done over SSH by hand. A panel is worth adding when the recurring work is the problem rather than the initial build: certificate renewals, firewall changes, worker restarts on each deploy and patch windows. If you self host the panel, keep it on separate infrastructure from the servers it manages.
How long does it take to set up a production server?
About two to four hours by hand for a single application server, and much of that is waiting on DNS propagation, a first backup and a certificate rather than typing. The figure that matters is the second build: if the process lives in a script or a provisioning template it takes minutes and is identical every time.