# Ubuntu Server Hardening — The Complete Guide
**Stack Covered:**
– Ubuntu Server (22.04 LTS / 24.04 LTS) — initial setup & system security
– SSH — key-based auth, port change, fail2ban, and more
– System hardening — firewall (UFW), automatic updates, users, sudo, kernel tweaks
– Apache2 — TLS, headers, modules, permissions, and DoS protection
– Databases — MySQL / MariaDB and PostgreSQL hardening
– Database tools — phpMyAdmin, Adminer, pgAdmin considerations
– Day-0 must-dos — monitoring, logging, backups, and a full hardening checklist
> **Warning:** Read the whole section *before* running commands. Some steps (especially SSH changes and firewall rules) can lock you out of your server if done incorrectly. Always keep a second terminal session open while testing, and never close your session until you’ve verified you can reconnect.
—
## Table of Contents
1. [Before You Start — Requirements & Mindset](#1-before-you-start–requirements–mindset)
2. [Part 1 — Initial Server Setup](#2-part-1–initial-server-setup)
3. [Part 2 — SSH Hardening](#3-part-2–ssh-hardening)
4. [Part 3 — System Hardening](#4-part-3–system-hardening)
– [Keep the System Updated](#keep-the-system-updated)
– [Create a Non-Root User](#create-a-non-root-user)
– [Configure Sudo Properly](#configure-sudo-properly)
– [Firewall with UFW](#firewall-with-ufw)
– [Fail2ban](#fail2ban)
– [Automatic Security Updates](#automatic-security-updates)
– [Time Synchronization (NTP)](#time-synchronization-ntp)
– [Disable Unused Services & Kernel Modules](#disable-unused-services–kernel-modules)
– [Kernel & Sysctl Hardening](#kernel–sysctl-hardening)
– [Logging & Auditing (auditd)](#logging–auditing-auditd)
5. [Part 4 — Apache2 Hardening](#5-part-4–apache2-hardening)
– [Installation & Basic Hygiene](#installation–basic-hygiene)
– [Hide Server Identity](#hide-server-identity)
– [Disable Unused Modules](#disable-unused-modules)
– [Secure Headers](#secure-headers)
– [TLS / SSL Configuration](#tls–ssl-configuration)
– [Directory Permissions & Options](#directory-permissions–options)
– [Restrict Sensitive File Types](#restrict-sensitive-file-types)
– [Limit Request Size](#limit-request-size)
– [Rate Limiting with mod_evasive / mod_qos](#rate-limiting-with-mod_evasive–mod_qos)
– [Run Apache as a Restricted User](#run-apache-as-a-restricted-user)
– [Separate Virtual Hosts](#separate-virtual-hosts)
6. [Part 5 — Database Hardening (MySQL / MariaDB)](#6-part-5–database-hardening-mysql–mariadb)
– [Install & Run `mysql_secure_installation`](#install–run-mysql_secure_installation)
– [Bind to Localhost Only](#bind-to-localhost-only)
– [Create Least-Privilege Users](#create-least-privilege-users)
– [Harden the Config File](#harden-the-config-file)
– [Logging & Monitoring](#logging–monitoring)
7. [Part 6 — Database Hardening (PostgreSQL)](#7-part-6–database-hardening-postgresql)
– [Install & Initial Security](#install–initial-security)
– [pg_hba.conf — Authentication Rules](#pghbaconf–authentication-rules)
– [Least-Privilege Roles](#least-privilege-roles)
– [postgresql.conf Tweaks](#postgresqlconftweaks)
8. [Part 7 — Database Tools (phpMyAdmin, Adminer, pgAdmin)](#8-part-7–database-tools-phpmyadmin-adminer-pgadmin)
– [General Rules for Web-Based DB Tools](#general-rules-for-web-based-db-tools)
– [phpMyAdmin Specifics](#phpmyadmin-specifics)
– [Adminer Specifics](#adminer-specifics)
– [pgAdmin Specifics](#pgadmin-specifics)
9. [Part 8 — Other Default Must-Dos](#9-part-8–other-default-must-dos)
– [Backups — 3-2-1 Strategy](#backups–3-2-1-strategy)
– [Monitoring & Alerts](#monitoring–alerts)
– [Intrusion Detection (Lynis, rkhunter, chkrootkit)](#intrusion-detection-lynis-rkhunter-chkrootkit)
– [Logwatch / Log Rotation](#logwatch–log-rotation)
– [Physical & Cloud-Level Security](#physical–cloud-level-security)
– [AppArmor](#apparmor)
– [Disable IPv6 (only if not needed)](#disable-ipv6-only-if-not-needed)
– [Docker / Container Security](#docker–container-security)
10. [Part 9 — The Full Hardening Checklist](#10-part-9–the-full-hardening-checklist)
11. [Troubleshooting & Recovery](#11-troubleshooting–recovery)
—
## 1. Before You Start — Requirements & Mindset
### Required knowledge
This guide assumes you have:
– A fresh (or existing) Ubuntu Server installation (22.04 LTS or 24.04 LTS recommended).
– Root or sudo access.
– Ability to reach the server via the console/provider panel *in case you lock yourself out*.
– A domain name (for TLS with Let’s Encrypt) — *recommended but not required for most hardening steps*.
### Security mindset
| Principle | What it means in practice |
|—|—|
| **Least privilege** | Every user, process, and DB role gets only the permissions it needs. Nothing more. |
| **Defense in depth** | Multiple layers: firewall *and* SSH keys *and* fail2ban *and* AppArmor, etc. No single point of failure. |
| **Assume breach** | Log everything, monitor everything, back up everything. Plan for the day you get hacked. |
| **Prefer keys over passwords** | Password authentication should be the exception, not the rule. |
| **Keep it simple** | Every service you install is more attack surface. Uninstall what you don’t need. |
| **Test everything** | Changes go wrong. Keep a second SSH session, test after every change. |
### Reference: versions
| Tool | Ubuntu 22.04 | Ubuntu 24.04 |
|—|—|—|
| Apache | 2.4.52+ | 2.4.58+ |
| MariaDB | 10.6 | 10.11 |
| MySQL | 8.0 | 8.0/8.4 |
| PostgreSQL | 14 | 16 |
| OpenSSH | 8.9 | 9.6 |
—
## 2. Part 1 — Initial Server Setup
### 2.1 Create a non-root user (do this immediately)
A fresh Ubuntu server only has a `root` user and whatever account the cloud provider created. You should never log in as `root` for daily work.
“`bash
# Log in as the provider-created user or root, then:
sudo adduser deployser
“`
Fill in a **strong** password and the optional info (you can press Enter to skip).
### 2.2 Verify sudo works
“`bash
sudo usermod -aG sudo deployser
# Test in a NEW session:
su – deployser
sudo whoami
“`
You should see `root`. If sudo works, you can now do everything as `deployser`.
### 2.3 Set the hostname
“`bash
sudo hostnamectl set-hostname web01
# Verify (optional): add to /etc/hosts
echo “127.0.1.1 web01” | sudo tee -a /etc/hosts
“`
### 2.4 Update everything right away
“`bash
sudo apt update && sudo apt upgrade -y
sudo apt dist-upgrade -y
sudo reboot
“`
> A freshly installed server is usually days or weeks behind on security patches. **Never** expose a server to the internet before running this.
### 2.5 Install basic useful packages
“`bash
sudo apt install -y \
curl wget unzip htop vim nano \
ufw fail2ban unattended-upgrades \
net-tools lsof systemd-timesyncd \
openssl ca-certificates \
apt-listchanges apticron
“`
(We’ll configure most of these in later sections.)
### 2.6 Check open ports right now
“`bash
sudo ss -tulpn
“`
On a fresh server you should see **only**:
– `:22` (SSH)
– `:53` (systemd-resolved, DNS, *normal*)
– maybe `:80`/`:443` if you installed Apache already
Anything else is a service you need to justify. Note the list — you’ll close everything you don’t need with UFW in Part 3.
—
## 3. Part 2 — SSH Hardening
SSH is the #1 attack target on any public server. This section is the single highest-impact thing you can do.
### 3.1 Generate a strong key pair (on your local machine, NOT the server)
On **your workstation** (Windows PowerShell / macOS / Linux):
“`bash
ssh-keygen -t ed25519 -a 100 -C “winningtiger-web01” -f ~/.ssh/web01_ed25519
“`
– `ed25519` is faster and stronger than RSA for new keys.
– If you must use RSA (older tools), use `-t rsa -b 4096`.
### 3.2 Copy the public key to the server
“`bash
# From your workstation:
ssh-copy-id -i ~/.ssh/web01_ed25519.pub deployser@YOUR_SERVER_IP
“`
If `ssh-copy-id` isn’t available on Windows, do it manually:
“`bash
# On the server, as deployser:
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys
# paste the contents of your PUBLIC key file (web01_ed25519.pub)
chmod 600 ~/.ssh/authorized_keys
“`
### 3.3 Test key login BEFORE touching anything else
“`bash
# From your workstation:
ssh -i ~/.ssh/web01_ed25519 deployser@YOUR_SERVER_IP
“`
If you get in with no password prompt — good. If not, **stop** and fix the key setup before continuing. Also verify the second terminal session works — you’ll need it as a lifeline.
### 3.4 Harden the SSH daemon
Edit the SSH config:
“`bash
sudo nano /etc/ssh/sshd_config
“`
Apply these settings (each is explained):
| Setting | Value | Why |
|—|—|—|
| `Port` | `2222` (or other high port) | Mass scanners only attack port 22. Moving the port eliminates ~99% of bot noise. *(Optional but recommended.)* |
| `PermitRootLogin` | `no` (or `prohibit-password`) | Never allow root password login. `prohibit-password` still allows keys for root in emergencies. |
| `PubkeyAuthentication` | `yes` | Enable key login (default). |
| `PasswordAuthentication` | `no` | **Only** disable this *after* you’ve verified key login works from a second session. |
| `KbdInteractiveAuthentication` | `no` | Disables keyboard-interactive passwords. |
| `ChallengeResponseAuthentication` | `no` | Older alias for the above. |
| `UsePAM` | `yes` | Keep PAM. |
| `PermitEmptyPasswords` | `no` | Obviously. |
| `MaxAuthTries` | `3` | Limit password attempts before disconnect. |
| `LoginGraceTime` | `30` | 30 seconds to authenticate or drop. |
| `X11Forwarding` | `no` | Rarely needed, reduces risk. |
| `AllowAgentForwarding` | `no` | Unless you know you need it. |
| `AllowTcpForwarding` | `yes` | Needed for `ssh -L/-R` tunnels (often useful for DB access). |
| `ClientAliveInterval` | `300` | Drop dead connections every 5 min. |
| `ClientAliveCountMax` | `2` | After 2 missed pings, disconnect. |
| `AllowUsers` | `deployser` | Only this user may SSH. *(Add your other accounts, e.g. `deployser backupuser`.)* |
| `MaxSessions` | `4` | Limit parallel sessions. |
| `LogLevel` | `VERBOSE` | More detail in `/var/log/auth.log`. |
Example final `/etc/ssh/sshd_config` (relevant lines):
“`ini
# Network
Port 2222
ListenAddress 0.0.0.0
ListenAddress ::
# Authentication
PermitRootLogin no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitEmptyPasswords no
UsePAM yes
MaxAuthTries 3
LoginGraceTime 30
# Forwarding
AllowAgentForwarding no
AllowTcpForwarding yes
X11Forwarding no
# Connection
ClientAliveInterval 300
ClientAliveCountMax 2
MaxSessions 4
# Users
AllowUsers deployser
LogLevel VERBOSE
“`
> **Tip — Keep root keys working in emergencies:** create `/root/.ssh/authorized_keys` with your public key *before* disabling root password login. That way if you ever lose `deployser`, you can still get in as root with your key. Most cloud providers also offer a web-console fallback.
### 3.5 Validate the config, then restart
“`bash
# Syntax check — MUST pass before restarting:
sudo sshd -t
# Reload (does NOT disconnect existing sessions):
sudo systemctl reload ssh
“`
If `sshd -t` reports errors, fix them before reloading.
> **Never run `systemctl restart ssh` from a session you aren’t sure about.** Use `reload` — it keeps existing connections alive.
### 3.6 Test from the second terminal
“`bash
# From a NEW terminal on your workstation:
ssh -p 2222 -i ~/.ssh/web01_ed25519 deployser@YOUR_SERVER_IP
“`
If you can’t connect, use your still-open first session to fix things. **If you have only one session, add a cron-friendly fallback or a second `@reboot` entry that reopens port 22 — or simply keep a VNC/provider console handy.**
### 3.7 Fail2ban (SSH brute-force protection)
Install and configure:
“`bash
sudo apt install -y fail2ban
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
“`
Set in `[DEFAULT]`:
“`ini
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 YOUR_HOME_IP/32
“`
Enable the SSH jail (adjust `port` to your custom SSH port if you changed it):
“`ini
[sshd]
enabled = true
port = 2222
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 4
bantime = 2h
“`
Then enable and `reload`/`restart`:
“`bash
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
sudo fail2ban-client status sshd
“`
You should see:
“`
Status for the jail: sshd
|- Filter
| |- Currently failed: 0
| |- Total failed: 0
`- Actions
|- Currently banned: 0
“`
### 3.8 Verify SSH security
“`bash
# Test with a wrong password — should fail quickly:
# (from another machine, using password auth should be impossible now)
# Check for brute-force attempts:
sudo tail -f /var/log/auth.log | grep -i “failed\|invalid”
“`
—
## 4. Part 3 — System Hardening
### Keep the System Updated
Check for pending updates frequently (or automate it — see below):
“`bash
sudo apt update && sudo apt upgrade -y
sudo apt autoremove -y
sudo apt autoclean
“`
**Enable automatic security updates:**
“`bash
sudo dpkg-reconfigure unattended-upgrades
“`
Choose **Yes**. Then edit:
“`bash
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
“`
Uncomment / ensure:
“`ini
Unattended-Upgrade::Allowed-Origins {
“${distro_id}:${distro_codename}”;
“${distro_id}:${distro_codename}-security”;
“${distro_id}ESMApps:${distro_codename}-apps-security”;
“${distro_id}ESM:${distro_codename}-infra-security”;
};
Unattended-Upgrade::Remove-Unused-Kernel-Packages “true”;
Unattended-Upgrade::Remove-Unused-Dependencies “true”;
Unattended-Upgrade::Automatic-Reboot “true”;
Unattended-Upgrade::Automatic-Reboot-Time “03:00”;
“`
Test it:
“`bash
sudo unattended-upgrade –dry-run –debug
“`
### Create a Non-Root User (recap)
You already did this in Part 1 — but ensure:
“`bash
# Disable root shell (after you’ve confirmed deployser works):
sudo passwd -l root
# (use -u to unlock later if ever needed)
“`
### Configure Sudo Properly
1. **Set a timeout** so an unlocked root shell doesn’t persist:
“`bash
sudo nano /etc/sudoers.d/00-timeout
“`
“`ini
Defaults timestamp_timeout=10 # re-ask password after 10 min of inactivity
“`
2. **Only the `sudo` group gets sudo** (default is fine; just don’t add random users).
3. **For advanced setups,** create an admin group with specific privilege paths — e.g., a `wwwadmins` group that can only restart Apache:
“`bash
# Create group:
sudo groupadd wwwadmins
# Allow restart/reload of apache only:
echo ‘%wwwadmins ALL=(ALL) NOPASSWD: /usr/sbin/systemctl restart apache2, /usr/sbin/systemctl reload apache2’ | \
sudo tee /etc/sudoers.d/wwwadmins
“`
### Firewall with UFW
**The single most important system-level control.** UFW is a wrapper around iptables/nftables that’s easy to manage.
“`bash
# Install:
sudo apt install -y ufw
# Set defaults (deny ALL incoming, allow all outgoing):
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH on your custom port (must match sshd_config):
sudo ufw allow 2222/tcp comment ‘SSH’
# Allow web traffic:
sudo ufw allow 80/tcp comment ‘HTTP’
sudo ufw allow 443/tcp comment ‘HTTPS’
# Allow anything else you genuinely need, e.g.:
# sudo ufw allow 5432/tcp from YOUR_HOME_IP/32 comment ‘PostgreSQL from home only’
# Enable the firewall:
sudo ufw –force enable
# Show status:
sudo ufw status verbose
“`
**Notes:**
– Enable UFW **before** exposing anything else.
– If you changed the SSH port, **open the new port before** disabling port 22.
– For databases, only allow the specific source IP that needs access — never `0.0.0.0/0`.
### Fail2ban
Already covered in Part 2 for SSH. You can add jails for Apache and other services later.
### Automatic Security Updates
Covered above — move on if done.
### Time Synchronization (NTP)
Accurate time is critical for log correlation and TLS. On Ubuntu, `systemd-timesyncd` is usually already enabled:
“`bash
sudo timedatectl set-ntp true
timedatectl status
“`
If you see `System clock synchronized: yes` and `NTP service: active`, you’re good. Otherwise:
“`bash
sudo systemctl enable –now systemd-timesyncd
“`
### Disable Unused Services & Kernel Modules
“`bash
# List listening services:
sudo ss -tulpn
# Disable anything you don’t need, e.g. CUPS (if present):
sudo systemctl disable –now cups
sudo systemctl disable –now cups-browsed
# What about these? Check each:
# avahi-daemon (mDNS — usually not needed on servers)
# bluetooth (servers don’t need it)
# ModemManager (not needed)
# multipathd (only if using multipath storage)
sudo systemctl disable –now avahi-daemon bluetooth ModemManager 2>/dev/null
# Disable unused kernel modules:
sudo nano /etc/modprobe.d/blacklist.conf
“`
Add (only if you truly don’t need them — do NOT blacklist modules your hardware needs):
“`ini
# Disable unused filesystems
install cramfs /bin/true
install freevxfs /bin/true
install jffs2 /bin/true
install hfs /bin/true
install hfsplus /bin/true
install squashfs /bin/true
install udf /bin/true
install vfat /bin/true
# Disable uncommon network protocols
install dccp /bin/true
install sctp /bin/true
install rds /bin/true
install tipc /bin/true
“`
> **Warning:** These module blacklists can break things (e.g., vfat is needed for some USB sticks/boot partitions). Only apply what your workload allows, and test after a reboot.
### Kernel & Sysctl Hardening
Create `/etc/sysctl.d/99-hardening.conf`:
“`bash
sudo nano /etc/sysctl.d/99-hardening.conf
“`
“`ini
# —- Network hardening —-
# Disable IP forwarding (unless you run a router/VPN)
net.ipv4.ip_forward = 0
# Disable ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# Ignore ICMP broadcasts & bogus responses
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
# Disable source routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
# TCP SYN cookies (protects against SYN flood DoS)
net.ipv4.tcp_syncookies = 1
# Log martians (packets with impossible source addresses)
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
# —- Protection against common exploits —-
# Hard link & symlink protection
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
# Restrict dmesg (don’t leak kernel info)
kernel.dmesg_restrict = 1
# Restrict kernel pointer visibility (mitigates info leaks)
kernel.kptr_restrict = 2
# Restrict perf events to privileged users
kernel.perf_event_paranoid = 3
# Restrict ptrace (prevents process injection for non-root)
kernel.yama.ptrace_scope = 1
# Restrict core dumps (avoid leaking sensitive data)
fs.suid_dumpable = 0
kernel.core_uses_pid = 1
# Randomize memory layout (ASLR) — already default, but explicit
kernel.randomize_va_space = 2
# —- IPv6 (only if you keep IPv6 enabled) —-
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
“`
Apply it:
“`bash
sudo sysctl –system
# Verify a few:
sudo sysctl net.ipv4.tcp_syncookies
sudo sysctl kernel.kptr_restrict
“`
### Logging & Auditing (auditd)
#### Enable auditd
“`bash
sudo apt install -y auditd audispd-plugins
sudo systemctl enable –now auditd
“`
#### Add key audit rules
“`bash
sudo nano /etc/audit/rules.d/hardening.rules
“`
“`ini
## Track user & group changes
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/sudoers -p wa -k identity
-w /etc/sudoers.d/ -p wa -k identity
## Track privilege escalation
-w /usr/bin/sudo -p x -k sudo_exec
-w /usr/bin/su -p x -k su_exec
## Track system config changes
-w /etc/systemd/ -p wa -k systemd
-w /etc/sysctl.d/ -p wa -k sysctl
-w /etc/ssh/sshd_config -p wa -k sshd_config
-w /etc/ufw/ -p wa -k ufw
-w /etc/apparmor.d/ -p wa -k apparmor
## Track network config
-w /etc/hosts -p wa -k hosts
-w /etc/hostname -p wa -k hostname
-w /etc/network/ -p wa -k network
## Track key binaries & libraries (watch for tampering)
-w /usr/sbin/apache2 -p x -k apache
-w /usr/sbin/mysqld -p x -k mysql
-w /usr/sbin/postgres -p x -k postgres
-w /usr/bin/sshd -p x -k sshd
“`
**Note on `apparmor` rule:** if `/etc/apparmor.d/` doesn’t have access, split it into individual profile paths. Simpler variant:
“`ini
## AppArmor profiles
-w /etc/apparmor.d/usr.sbin.sshd -p wa -k apparmor
-w /etc/apparmor.d/usr.lib.apache2.mpm-worker -p wa -k apparmor
“`
(Only add lines for files that exist — you can check with `ls /etc/apparmor.d/`.)
Load and verify:
“`bash
sudo augenrules –load
sudo auditctl -l
“`
Check logs:
“`bash
sudo ausearch -k identity -ts today
sudo ausearch -m avc -ts today # SELinux-like denials (AppArmor)
“`
#### Tighten syslog
“`bash
sudo nano /etc/rsyslog.conf
“`
Uncomment these line pairs (they’re commented by default):
“`ini
# auth,authpriv.* /var/log/auth.log
# *.*;auth,authpriv.none -/var/log/syslog
“`
Then:
“`bash
sudo systemctl restart rsyslog
“`
—
## 5. Part 4 — Apache2 Hardening
### Installation & Basic Hygiene
“`bash
sudo apt install -y apache2
sudo systemctl enable –now apache2
“`
**First steps:**
“`bash
# Remove the default page (it leaks version info & exists as a placeholder):
sudo rm -f /var/www/html/index.html
# Check what modules are loaded:
sudo apache2ctl -M
“`
### Hide Server Identity
Edit `/etc/apache2/conf-available/security.conf`:
“`bash
sudo nano /etc/apache2/conf-available/security.conf
“`
Set:
“`ini
ServerTokens Prod
ServerSignature Off
TraceEnable Off
“`
– `ServerTokens Prod` → sends `Apache` instead of `Apache/2.4.58 (Ubuntu)`
– `ServerSignature Off` → no footer on error pages
– `TraceEnable Off` → **blocks HTTP TRACE/TRACK** (prevents cross-site tracing attacks)
Apply:
“`bash
sudo a2enconf security
sudo systemctl reload apache2
“`
Verify:
“`bash
curl -I http://localhost/ | grep Server
# Should output: Server: Apache
“`
### Disable Unused Modules
“`bash
# Remove modules you don’t use. Safe to disable on most setups:
sudo a2dismod autoindex # directory listing
sudo a2dismod status # server-status (if you don’t use it)
sudo a2dismod info # server-info
sudo a2dismod userdir # public_html for users
sudo a2dismod cgi # CGI scripts (unless needed)
sudo a2dismod negotiation # content negotiation (can leak files)
sudo a2dismod dav # WebDAV (unless explicitly needed)
sudo systemctl reload apache2
“`
> If you need `server-status`, keep it but **restrict access** to your IP only (see below).
**Block directory listing as a habit:**
“`bash
sudo nano /etc/apache2/apache2.conf
“`
Ensure the `/var/www/` Directory block looks like this:
“`apache
<Directory /var/www/>
Options -Indexes -FollowSymLinks
AllowOverride None
Require all granted
</Directory>
“`
### Secure Headers
Create `/etc/apache2/conf-available/security-headers.conf`:
“`bash
sudo nano /etc/apache2/conf-available/security-headers.conf
“`
“`apache
# Modern security headers
Header always set X-Content-Type-Options “nosniff”
Header always set X-Frame-Options “SAMEORIGIN”
Header always set Referrer-Policy “strict-origin-when-cross-origin”
Header always set Permissions-Policy “geolocation=(), microphone=(), camera=()”
Header always set Cross-Origin-Opener-Policy “same-origin”
Header always set Cross-Origin-Resource-Policy “same-origin”
# CSP — adjust to YOUR site. This is a reasonable starting point
# for a typical API + dashboard stack:
Header always set Content-Security-Policy “default-src ‘self’; script-src ‘self’ ‘unsafe-inline’ ‘unsafe-eval’ https:; style-src ‘self’ ‘unsafe-inline’ https:; img-src ‘self’ data: https:; font-src ‘self’ https:; connect-src ‘self’ https:; frame-ancestors ‘none’; base-uri ‘self’; form-action ‘self'”
# Hide Apache version entirely
Header always unset X-Powered-By
Header always unset Server
# Enable HTTP Strict Transport Security — ONLY if you serve 100% HTTPS on this vhost:
# (remove the comment when you have valid TLS working)
# Header always set Strict-Transport-Security “max-age=31536000; includeSubDomains; preload”
“`
Enable and reload:
“`bash
sudo a2enmod headers
sudo a2enconf security-headers
sudo systemctl reload apache2
“`
Verify:
“`bash
curl -I http://localhost/
“`
> **CSP warning:** The above CSP allows `’unsafe-inline’` in `script-src`, which weakens XSS protection. If your app is a modern SPA, tighten it to `script-src ‘self’`. Test carefully — CSP mistakes break your site.
### TLS / SSL Configuration
#### Get a certificate
Use Let’s Encrypt (free):
“`bash
sudo apt install -y certbot python3-certbot-apache
sudo certbot –apache -d example.com -d www.example.com
“`
For a reverse-proxy setup (API backend), you may prefer `–webroot`. Either way, after you have the certificate:
“`bash
# Test renewal:
sudo certbot renew –dry-run
# Auto-renewal is installed as a systemd timer — verify:
sudo systemctl list-timers | grep certbot
“`
#### Strong TLS config
Create `/etc/apache2/conf-available/ssl-hardening.conf`:
“`bash
sudo nano /etc/apache2/conf-available/ssl-hardening.conf
“`
“`apache
<IfModule mod_ssl.c>
# Modern, secure protocols & ciphers
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder on
SSLCompression off
# OCSP Stapling — improves TLS performance & privacy
SSLUseStapling on
SSLStaplingResponderTimeout 5
SSLStaplingReturnResponderErrors off
# Session caching (performance), with a short timeout
SSLSessionTickets off
</IfModule>
“`
Enable and reload:
“`bash
sudo a2enmod ssl
sudo a2enconf ssl-hardening
sudo systemctl reload apache2
“`
Verify with:
“`bash
# From your workstation (or use ssllabs.com):
nmap –script ssl-enum-ciphers -p 443 example.com
# Or simply:
openssl s_client -connect example.com:443 -tls1_1 </dev/null 2>&1 | grep “Protocol” # should FAIL
openssl s_client -connect example.com:443 </dev/null 2>&1 | grep “Protocol” # should say TLSv1.3
“`
**Recommended:** test your site on [SSL Labs](https://www.ssllabs.com/ssltest/) — aim for **A or A+**.
> To get **A+**, enable HSTS via the security-headers file above and ensure your cert covers all subdomains.
### Directory Permissions & Options
“`bash
# Your web root should be owned by the right user:
sudo chown -R www-data:www-data /var/www/example.com
sudo chmod -R 750 /var/www/example.com
# Sensitive files that should NEVER be served:
# – .env files
# – .git directories
# – backup files (*.bak, *.sql)
# – config files
“`
Add this to your vhost:
“`apache
<FilesMatch “\.(env|sql|bak|old|save|log|ini|conf|sh|py|rb)$”>
Require all denied
</FilesMatch>
<DirectoryMatch “^/.*/\.git”>
Require all denied
</DirectoryMatch>
“`
### Restrict Sensitive File Types
Prevent execution of scripts in upload directories, and deny `.htaccess` in non-standard places:
“`apache
# In the vhost or global config:
<Directory /var/www/example.com/uploads/>
php_admin_flag engine off
RemoveHandler .php .phtml .php3
RemoveType .php .phtml .php3
php_flag engine off
<FilesMatch “\.(php|phar|phtml)$”>
Require all denied
</FilesMatch>
Options -ExecCGI -Indexes
</Directory>
“`
### Limit Request Size
Prevent oversized request DoS:
“`bash
sudo nano /etc/apache2/mods-enabled/mime.conf
# (or in your vhost)
“`
“`apache
# In vhost:
LimitRequestBody 10485760 # 10 MB max request body
“`
Also set PHP limits (if using PHP):
“`ini
# /etc/php/*/apache2/php.ini
post_max_size = 10M
upload_max_filesize = 10M
max_execution_time = 30
max_input_time = 60
memory_limit = 128M
“`
### Rate Limiting with mod_evasive / mod_qos
Install `libapache2-mod-evasive` to blunt brute-force and DoS:
“`bash
sudo apt install -y libapache2-mod-evasive
sudo nano /etc/apache2/mods-available/evasive.conf
“`
“`apache
<IfModule mod_evasive20.c>
DOSHashTableSize 3097
DOSPageCount 10 # max requests per page per interval
DOSSiteCount 60 # max requests per site per interval
DOSPageInterval 1 # interval in seconds
DOSSiteInterval 1
DOSBlockingPeriod 10 # block for 10 seconds
DOSEmailNotify you@example.com
DOSSystemCommand “su -s /bin/sh -c ‘fail2ban-client set apache-bruteforce banip <ip>’ root”
</IfModule>
“`
“`bash
sudo a2enmod evasive
sudo systemctl restart apache2
“`
Careful: too-aggressive settings will block legitimate users. Test with a real workload.
> **Note (Ubuntu 24.04):** `mod_evasive` may not be packaged for newer versions. Alternative: use a reverse proxy like Nginx in front, or `mod_security` + OWASP CRS rules:
“`bash
sudo apt install -y libapache2-mod-security2
sudo cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf
sudo nano /etc/modsecurity/modsecurity.conf
# Set: SecRuleEngine On
sudo wget https://github.com/coreruleset/coreruleset/archive/refs/tags/v4.0.0.tar.gz -O /tmp/crs.tgz
sudo tar -xzf /tmp/crs.tgz -C /etc/modsecurity/
sudo mv /etc/modsecurity/coreruleset-4.0.0 /etc/modsecurity/crs
sudo cp /etc/modsecurity/crs/crs-setup.conf.example /etc/modsecurity/crs/crs-setup.conf
# Then reference it in your vhost or modsecurity config
“`
### Run Apache as a Restricted User
Apache already runs as `www-data`. Verify it’s not running as `root`:
“`bash
ps aux | grep apache
“`
You should see the parent on `root` (that’s normal — it binds port 80/443) and workers on `www-data`. To tighten further in `/etc/apache2/apache2.conf`:
“`apache
User www-data
Group www-data
“`
> Never rename `www-data` to something else unless you fully understand how Ubuntu Apache packaging uses it (setuid log dirs, etc.).
### Separate Virtual Hosts
One vhost per site/domain. Never put multiple sites in one vhost, and never run experimental Django/Node dev servers on port 80.
**Layout:**
“`text
/etc/apache2/sites-available/
├── 000-default.conf → disable this
├── example.com.conf → your real vhost
“`
Example `example.com.conf`:
“`apache
<VirtualHost *:80>
ServerName example.com
ServerAlias www.example.com
Redirect permanent / https://example.com/
</VirtualHost>
<VirtualHost *:443>
ServerName example.com
ServerAlias www.example.com
DocumentRoot /var/www/example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
# Harden the virtual host
Header always set Strict-Transport-Security “max-age=31536000; includeSubDomains; preload”
<Directory /var/www/example.com>
Options -Indexes
AllowOverride None
Require all granted
</Directory>
# Block sensitive files
<FilesMatch “\.(env|sql|bak|old|log)$”>
Require all denied
</FilesMatch>
# Example reverse proxy to an API on localhost:3000
# ProxyPreserveHost On
# ProxyPass /api/ http://127.0.0.1:3000/api/
# ProxyPassReverse /api/ http://127.0.0.1:3000/api/
ErrorLog ${APACHE_LOG_DIR}/example.com_error.log
CustomLog ${APACHE_LOG_DIR}/example.com_access.log combined
</VirtualHost>
“`
Enable only what you need:
“`bash
sudo a2dissite 000-default
sudo a2ensite example.com.conf
sudo apache2ctl configtest # syntax check before reload!
sudo systemctl reload apache2
“`
—
## 6. Part 5 — Database Hardening (MySQL / MariaDB)
### Install & Run `mysql_secure_installation`
“`bash
sudo apt install -y mysql-server # OR: mariadb-server
sudo systemctl enable –now mysql
sudo mysql_secure_installation
“`
Answer **YES** to every prompt:
| Prompt | Answer |
|—|—|
| Validate password plugin? | **Yes** (STRONG) |
| Remove anonymous users? | **Yes** |
| Disallow root login remotely? | **Yes** |
| Remove test database? | **Yes** |
| Reload privilege tables? | **Yes** |
**Set a strong root password** when prompted. For MySQL 8 the root socket auth is the default on Ubuntu — keep it that way (root can only log in via `sudo mysql`).
### Bind to Localhost Only
“`bash
sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
# (for MariaDB: /etc/mysql/mariadb.conf.d/50-server.cnf)
“`
Find and set:
“`ini
[mysqld]
bind-address = 127.0.0.1
skip-networking = 0
port = 3306
“`
Then a commented-out `bind-address = 0.0.0.0` or `skip-external-locking` line is safe to leave. Restart:
“`bash
sudo systemctl restart mysql
“`
Verify it’s not reachable from outside:
“`bash
sudo ss -tulpn | grep 3306
# Should show 127.0.0.1:3306 only
“`
If you *must* allow remote access (e.g., from a dev machine), never bind to `0.0.0.0`. Instead:
“`bash
sudo ufw allow from YOUR_HOME_IP/32 to any port 3306 proto tcp
“`
And even then, use an SSH tunnel instead — it’s strictly better: DB stays on localhost, and you authenticate over SSH keys.
### Create Least-Privilege Users
**Never use `root` from your app.**
“`sql
— Log in as root via socket:
sudo mysql
— Create an app user with minimal privileges, restricted to one DB:
CREATE USER ‘app_user’@’localhost’ IDENTIFIED BY ‘A-Very-Long-Random-Password-Phrase!9$’;
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO ‘app_user’@’localhost’;
FLUSH PRIVILEGES;
“`
**Only** add more privileges if the app truly needs them (e.g., `CREATE` for migrations, `ALTER` for schema updates, `FILE` never). Never grant `ALL PRIVILEGES ON *.*` to an app user.
**Create a separate backup user** with just `LOCK TABLES, SELECT, SHOW VIEW, EVENT` on the relevant databases:
“`sql
CREATE USER ‘backup_user’@’localhost’ IDENTIFIED BY ‘Another-Long-Password!7’;
GRANT SELECT, LOCK TABLES, SHOW VIEW, EVENT ON myapp.* TO ‘backup_user’@’localhost’;
FLUSH PRIVILEGES;
“`
**Verify privileges:**
“`sql
SHOW GRANTS FOR ‘app_user’@’localhost’;
“`
### Harden the Config File
Append a hardening block to `/etc/mysql/mysql.conf.d/hardening.cnf` (or `mariadb.conf.d/99-hardening.cnf`):
“`bash
sudo nano /etc/mysql/mysql.conf.d/hardening.cnf
“`
“`ini
[mysqld]
# Don’t allow filesystem reads via SQL (SELECT … INTO OUTFILE)
secure_file_priv = /var/lib/mysql-files
local-infile = 0
# Don’t show version banner on errors
version_tokens_session = OFF
# Privileges & safe mode
safe-user-create = 1
symbolic-links = 0
# Logging
log_error = /var/log/mysql/error.log
general_log = 0
log_warnings = 2
# Slow query log (find bad queries — also useful for attack detection)
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 2
# Max connections (prevent resource exhaustion)
max_connections = 100
max_connect_errors = 100
# Timeouts
connect_timeout = 5
wait_timeout = 600
interactive_timeout = 600
# Table/memory limits
max_allowed_packet = 16M
table_open_cache = 2000
# Disable networking unless you ONLY use it locally
# skip-networking
# (MariaDB only) discourage extensions that read files
# plugin-load-add = none
“`
> Ubuntu’s MySQL may not support `safe-user-create`; it’s harmless to include. Validate after restart — if MySQL fails to start, remove the offending line.
Restart and check logs:
“`bash
sudo systemctl restart mysql
sudo tail -f /var/log/mysql/error.log
“`
### Logging & Monitoring
“`bash
# Check who’s connected right now:
sudo mysql -e “SHOW PROCESSLIST;”
# Check for suspicious users:
sudo mysql -e “SELECT user, host, plugin, password_expired FROM mysql.user;”
# Check for users with global privileges (red flags):
sudo mysql -e “SHOW GRANTS;”
# Repeated failed logins are logged to:
sudo grep -i “access denied” /var/log/mysql/error.log
“`
Install `mysqltuner` for a periodic health/security check:
“`bash
sudo apt install -y mysqltuner
sudo mysqltuner –user root 2>/dev/null | tee /root/mysqltuner-report.txt
“`
—
## 7. Part 6 — Database Hardening (PostgreSQL)
### Install & Initial Security
“`bash
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable –now postgresql
“`
PostgreSQL on Ubuntu is already secure-by-default in several ways:
– Runs as `postgres` user, NOT root.
– Listens only on localhost.
– Uses peer auth for local connections.
### pg_hba.conf — Authentication Rules
“`bash
sudo nano /etc/postgresql/16/main/pg_hba.conf
“`
Ensure it’s minimal and explicit. A hardened example:
“`ini
# TYPE DATABASE USER ADDRESS METHOD
# Local UNIX sockets — peer auth (matches OS username, no password)
local all postgres peer
local all all scram-sha-256
# Local TCP (127.0.0.1) — app connections use scram
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
# REMOTE connections — commented out / removed unless absolutely needed
# host all all 0.0.0.0/0 reject
“`
Key points:
– `peer` for the built-in `postgres` OS user.
– **`scram-sha-256`** instead of the default `md5` or `trust` — SCRAM auth is the modern standard (it uses a challenge-response mechanism that never sends the password over the wire).
– No `trust` entries — ever.
– No remote `host` lines unless required.
### Least-Privilege Roles
“`bash
sudo -u postgres psql
“`
“`sql
— App role with a strong password:
CREATE ROLE app_user WITH LOGIN PASSWORD ‘A-Very-Long-Random-Password-Phrase!8’;
— The app database:
CREATE DATABASE myapp OWNER app_user;
— Limit connections:
ALTER ROLE app_user CONNECTION LIMIT 20;
— Verify:
\du
\l
\q
“`
**Backup role (read-only):**
“`sql
CREATE ROLE backup_user WITH LOGIN PASSWORD ‘AnotherLongPassword*84’;
GRANT CONNECT ON DATABASE myapp TO backup_user;
GRANT USAGE ON SCHEMA public TO backup_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO backup_user;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO backup_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO backup_user;
“`
**Revoke default PUBLIC privileges** (PostgreSQL grants `PUBLIC` on the `public` schema by default):
“`sql
REVOKE ALL ON DATABASE myapp FROM PUBLIC;
“`
### postgresql.conf Tweaks
“`bash
sudo nano /etc/postgresql/16/main/postgresql.conf
“`
“`ini
# Connection
listen_addresses = ‘localhost’ # NEVER ‘*’ unless you use SSH tunnels only
port = 5432
max_connections = 100
# Authentication
password_encryption = scram-sha-256
# Logging (strongly recommended)
logging_collector = on
log_directory = ‘log’
log_filename = ‘postgresql-%Y-%m-%d.log’
log_connections = on
log_disconnections = on
log_duration = on
log_statement = ‘ddl’ # log DDL; ‘all’ if you need full audit
log_min_duration_statement = 1000 # log slow queries (>1s) — catches probes
# Resource limits
shared_buffers = 128MB # tune to your RAM (25% of total is a good start)
work_mem = 4MB
“`
Restart:
“`bash
sudo systemctl restart postgresql
sudo systemctl status postgresql
“`
Verify logging works:
“`bash
sudo tail -f /var/log/postgresql/postgresql-16-main.log
“`
—
## 8. Part 7 — Database Tools (phpMyAdmin, Adminer, pgAdmin)
Web-based database tools are **the most commonly hacked entry points** on real servers. Treat them with extreme caution.
### General Rules for Web-Based DB Tools
1. **Do not install them on the public internet.** Prefer a local-only install + SSH tunnel.
2. **If you must expose them**, put them behind:
– A strong password AND 2FA (via Apache Basic Auth + `auth2fa` module, or an external proxy)
– IP allowlisting (UFW/htaccess)
– A non-standard URL path
3. **Never run as root** — the web server user (`www-data`) should connect as a least-privilege DB user.
4. **Disable automatically** after use, or uninstall entirely.
5. **Keep them updated** — these tools are patched often for CVEs.
### PHPMyAdmin Specifics
“`bash
sudo apt install -y phpmyadmin
# Choose apache2 as the server, and say NO to dbconfig-common (configure manually)
“`
Stop Apache from auto-including it:
“`bash
sudo rm -f /etc/apache2/conf-enabled/phpmyadmin.conf
sudo systemctl reload apache2
“`
Now expose it only over an SSH tunnel:
“`bash
# From your WORKSTATION:
ssh -N -L 8080:127.0.0.1:80 deployser@YOUR_SERVER_IP
# Then open http://localhost:8080/phpmyadmin
“`
If you truly need it publicly, create a dedicated vhost with:
“`apache
<VirtualHost *:443>
ServerName db.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/db.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/db.example.com/privkey.pem
Alias “/dbadmin” /usr/share/phpmyadmin
<Directory /usr/share/phpmyadmin>
Options -Indexes
AllowOverride None
# Basic auth with a LONG random password:
AuthType Basic
AuthName “Restricted Area”
AuthUserFile /etc/apache2/phpmyadmin-passwd
Require valid-user
# Allow only your IP:
# Require ip 203.0.113.7
</Directory>
# Block phpMyAdmin’s own sensitive files
<FilesMatch “(config\.inc\.php|\.sql|\.bak)$”>
Require all denied
</FilesMatch>
</VirtualHost>
“`
Create the password file:
“`bash
sudo htpasswd -c /etc/apache2/phpmyadmin-passwd dba
“`
**Hardcode the config file** — never use the web setup:
“`bash
sudo nano /etc/phpmyadmin/config.inc.php
“`
“`php
<?php
$i = 0;
$i++;
$cfg[‘Servers’][$i][‘host’] = ‘127.0.0.1’;
$cfg[‘Servers’][$i][‘auth_type’] = ‘cookie’; // requires login
$cfg[‘Servers’][$i][‘user’] = ‘app_user’; // least-privilege user
$cfg[‘Servers’][$i][‘controluser’] = ‘pma_control’; // separate control user
$cfg[‘Servers’][$i][‘controlpass’] = ‘AnotherStrongPassword!32’;
$cfg[‘Servers’][$i][‘hide_db’] = ‘^(information_schema|performance_schema)$’;
$cfg[‘ExecTimeLimit’] = 0;
$cfg[‘MaxRows’] = 1000;
$cfg[‘ProtectBinary’] = ‘blob’;
$cfg[‘AllowUserDropDatabase’] = false;
“`
### Adminer Specifics
Adminer is a single PHP file — even easier to leave exposed. **Strongly prefer the SSH tunnel approach.**
If exposed, protect it with Basic Auth and restrict to your IP, and serve it over TLS only:
“`apache
<VirtualHost *:443>
ServerName db-tools.example.com
# … TLS setup …
Alias “/adminer” /var/www/adminer
<Directory /var/www/adminer>
Options -Indexes
Require valid-user
AuthType Basic
AuthName “DBA Only”
AuthUserFile /etc/apache2/adminer-passwd
<FilesMatch “adminer\.php$”>
Require all granted
</FilesMatch>
</Directory>
</VirtualHost>
“`
Also set PHP session hardening for the tool (in the vhost or `.htaccess` — but we disabled AllowOverride, so use vhost):
“`apache
<Directory /var/www/adminer>
php_flag session.cookie_httponly on
php_flag session.cookie_secure on
php_value session.cookie_samesite “Strict”
php_value session.cookie_lifetime 1800
</Directory>
“`
### PgAdmin Specifics
pgAdmin 4 is heavyweight — consider `psql` over an SSH tunnel for most work, or run pgAdmin4 in **desktop mode** on your workstation and connect through the tunnel:
“`bash
# On the server, allow PostgreSQL to listen on localhost only (already done).
# From your workstation, tunnel:
ssh -N -L 5433:127.0.0.1:5432 deployser@YOUR_SERVER_IP
# Then in pgAdmin on your workstation:
# Host: 127.0.0.1
# Port: 5433
# User: app_user
“`
If you must run pgAdmin as a web app on the server, treat it like phpMyAdmin: local-only + tunnel + strong auth + TLS + IP restriction. pgAdmin supports its own authentication as well — configure a strong master password and a **non-default admin user**.
—
## 9. Part 8 — Other Default Must-Dos
### Backups — 3-2-1 Strategy
> **3** copies of your data, on **2** different media, **1** off-site.
#### Database backups
**MySQL / MariaDB:**
“`bash
# Simple logical dump (no downtime, using the backup user):
mysqldump –single-transaction –routines –triggers \
–user=backup_user –password=’…’ myapp \
| gzip > /backups/myapp-$(date +%F).sql.gz
# Or use xtrabackup/mariabackup for hot physical backups:
sudo apt install -y mariadb-backup # or percona-xtrabackup-80
“`
**PostgreSQL:**
“`bash
sudo -u postgres pg_dump myapp | gzip > /backups/myapp-$(date +%F).sql.gz
“`
#### File backups (rsync to off-site)
Set up `rsync` to a remote location (another VPS, a NAS, or S3):
“`bash
# Example: rsync /var/www and /backups to a NAS
rsync -avz –delete /var/www/ /backups/ \
user@backupserver:/backups/web01/ \
–exclude ‘cache/’ –exclude ‘tmp/’
“`
Or automate with `duplicity`/`restic`/`borg` (all support encrypted off-site backups).
#### Automate with cron or systemd timers
“`cron
# crontab -e
30 2 * * * /usr/local/bin/backup-web01.sh >/dev/null 2>&1
“`
And **test restoring** periodically — a backup you can’t restore is worthless.
### Monitoring & Alerts
#### Basic local monitoring
“`bash
# CPU / memory / disk:
htop
# Disk usage:
df -h
# Disk I/O:
iostat
# Current connections:
ss -s
# Check for failed login attempts count:
sudo grep “Failed password” /var/log/auth.log | wc -l
“`
#### Install a lightweight monitor (netdata)
“`bash
curl -Ss https://get.netdata.cloud | bash
# Then open http://YOUR_SERVER_IP:19999 (or via SSH tunnel)
“`
Or use `glances`:
“`bash
sudo apt install -y glances
glances
“`
#### Automated alerts — Uptime Kuma (self-hosted) or UptimeRobot (free tier)
Both can watch your HTTP endpoints, ports, and even run TCP checks, then alert via email/Discord/Slack/Telegram.
#### Disk space alert
“`bash
sudo apt install -y ncdu # interactive disk usage
# Or a simple cron:
# */5 * * * * df -h / | awk ‘NR==2 && $5+0 > 90 {print | “mail -s \”DISK 90%\” you@example.com”}’
“`
### Intrusion Detection (Lynis, rkhunter, chkrootkit)
#### Lynis — the auditor
“`bash
sudo apt install -y lynis
sudo lynis audit system –quick | tee /root/lynis-report.txt
sudo lynis show details –report-file /root/lynis-report.txt
“`
Lynis gives you a **hardening index score** and a prioritized list of suggestions. Aim for 80+ on a hardened box.
#### rkhunter — rootkit hunter
“`bash
sudo apt install -y rkhunter
sudo rkhunter –update
sudo rkhunter –propupd # first run: baseline
# Daily check via cron:
echo “0 4 * * * /usr/bin/rkhunter –check –skip-keypress –report-warnings-only” | sudo tee /etc/cron.d/rkhunter
“`
#### chkrootkit
“`bash
sudo apt install -y chkrootkit
sudo chkrootkit
“`
> Rootkit scanners produce occasional false positives on unusual but legit binaries. Verify before acting.
### Logwatch / Log Rotation
“`bash
sudo apt install -y logwatch
# Daily summary to your email:
sudo logwatch –detail High –range today –mailto you@example.com
“`
Or set up `logrotate` (already default on Ubuntu) — verify your custom logs rotate:
“`bash
sudo nano /etc/logrotate.d/custom
“`
“`text
/var/log/mysql/*.log /var/log/postgresql/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 640 root adm
sharedscripts
}
“`
Run a dry-run test:
“`bash
sudo logrotate -d /etc/logrotate.d/custom
“`
### Physical & Cloud-Level Security
Even with a perfectly hardened OS, the layer below matters:
| Area | Must-do |
|—|—|
| **Cloud provider** | Enable 2FA on your provider account. Use a separate IAM user with least privilege. |
| **Security groups** | Use the provider’s security groups / network ACLs as a *second* firewall layer. Deny all by default, allow only 80/443/2222. |
| **Snapshots** | Take a snapshot before major changes. Automate weekly snapshots with retention. |
| **DNS** | Use DNSSEC, and if using Cloudflare, proxy origin IPs (orange cloud) — never leak your server IP in DNS records. |
| **Email** | If you send email, use a dedicated transactional service (Postmark/SES) with SPF/DKIM/DMARC — don’t run a mail server on the app box. |
| **Console/vendor access** | Set a vendor console password. Keep recovery access for lockout scenarios. |
### AppArmor
Ubuntu ships AppArmor enabled. Verify:
“`bash
sudo aa-status
“`
You should see profiles loaded for system services. Enable enforcement for key services:
“`bash
# Apache:
sudo aa-enforce /etc/apparmor.d/usr.sbin.apache2 2>/dev/null || true
# Example: tighten to complain mode for MySQL if it conflicts:
sudo aa-complain /etc/apparmor.d/usr.sbin.mysqld
“`
> AppArmor profiles for databases often need local additions (e.g., for custom data directories). `aa-complain` logs denials without blocking — use it to diagnose before going to `enforce`.
### Disable IPv6 (only if not needed)
If you don’t use IPv6 and your provider doesn’t assign IPv6 addresses:
“`bash
sudo nano /etc/default/grub
“`
Add to `GRUB_CMDLINE_LINUX`:
“`text
ipv6.disable=1
“`
Apply:
“`bash
sudo update-grub
sudo reboot
“`
> **Warning:** disabling IPv6 can break DNS (`AAAA` lookups), some cloud metadata services, and dual-stack setups. Only do this if you’re certain your network doesn’t need it. Otherwise, keep IPv6 and just make sure your UFW rules cover both stacks.
### Docker / Container Security
If you run containers on this box:
1. **Never run containers as root inside** — use an unprivileged user in the image.
2. **Read-only root filesystem** where possible:
“`yaml
# docker-compose.yml
services:
app:
read_only: true
security_opt:
– no-new-privileges:true
cap_drop:
– ALL
cap_add:
– NET_BIND_SERVICE
“`
3. **Pin image tags** — never `latest`.
4. **Scan images** — use `docker scan` or Trivy:
“`bash
sudo apt install -y docker.io
docker pull trivy:latest # or install trivy separately
trivy image your-image:tag
“`
5. **Restrict Docker socket access**:
– Only root should be in the `docker` group (membership = root-equivalent).
– Don’t bind-mount the docker socket into containers.
6. **Set resource limits:**
“`yaml
mem_limit: 512m
pids_limit: 100
restart: unless-stopped
“`
7. **Keep it updated** — `docker compose pull && docker compose up -d` on a schedule.
—
## 10. Part 9 — The Full Hardening Checklist
Run through this list after every fresh install. **Check the box when verified.**
### Day 0 — Access
– [ ] Created a non-root admin user (`deployser`) with a strong password
– [ ] Verified sudo works in a NEW session
– [ ] Generated an ed25519 SSH key pair on the workstation
– [ ] Installed the public key into `~/.ssh/authorized_keys`
– [ ] Verified key-only login from a second terminal
– [ ] Locked root password: `sudo passwd -l root`
– [ ] Backup root key added to `/root/.ssh/authorized_keys`
### Day 0 — SSH
– [ ] `PasswordAuthentication no` (after key login verified)
– [ ] `PermitRootLogin no`
– [ ] `Port` changed from 22 (optional but recommended)
– [ ] `MaxAuthTries 3`, `LoginGraceTime 30`
– [ ] `AllowUsers` restricts login to known users
– [ ] `X11Forwarding no`, `AllowAgentForwarding no`
– [ ] `LogLevel VERBOSE`
– [ ] `sudo sshd -t` passes; `systemctl reload ssh` done
– [ ] Re-login on new port with key works
### Day 0 — Updates
– [ ] `apt update && apt upgrade -y` run
– [ ] `unattended-upgrades` configured for security updates
– [ ] Rebooted and verified server comes back clean
### Day 0 — Firewall
– [ ] UFW default deny incoming / allow outgoing
– [ ] SSH port allowed (2222/tcp or your port)
– [ ] 80/443 allowed
– [ ] Database ports NOT exposed publicly
– [ ] `ufw status verbose` reviewed
### Day 0 — Users & Sudo
– [ ] Root shell disabled
– [ ] Sudo timeout set (10 min)
– [ ] Only trusted users in `sudo` group
### Day 1 — System
– [ ] `fail2ban` installed, sshd jail enabled and verified
– [ ] NTP synchronized (`timedatectl`)
– [ ] Unused services disabled (avahi, cups, bluetooth, ModemManager if present)
– [ ] Unused kernel modules blacklisted (with reboot test)
– [ ] `/etc/sysctl.d/99-hardening.conf` applied & verified
– [ ] `auditd` installed with identity/privilege rules loaded
– [ ] rsyslog logging auth to `/var/log/auth.log`
### Day 1 — Apache
– [ ] Default vhost disabled
– [ ] `ServerTokens Prod`, `ServerSignature Off`, `TraceEnable Off`
– [ ] Unused modules disabled (`autoindex`, `status`, `info`, `cgi`, `negotiation`)
– [ ] Security headers set (CSP, X-Frame-Options, nosniff, HSTS)
– [ ] TLS configured with Let’s Encrypt, renewal tested
– [ ] SSL protocols/ciphers hardened (`-TLSv1 -TLSv1.1`)
– [ ] SSL Labs score A or better
– [ ] `Options -Indexes` on `/var/www/`
– [ ] Sensitive file types blocked (`env`, `sql`, `git`, backups)
– [ ] Request body limit set (`LimitRequestBody`)
– [ ] PHP upload/config limits set if relevant
– [ ] mod_evasive/mod_security or equivalent rate limiting
– [ ] `apache2ctl configtest` passes
### Day 1 — Databases
– [ ] `mysql_secure_installation` completed
– [ ] Root only via socket (`sudo mysql`)
– [ ] DB listening on `127.0.0.1` only
– [ ] App user with least privilege only
– [ ] Backup user with read-only privileges
– [ ] `secure_file_priv` set; `local-infile=0`
– [ ] No anonymous users; no users with global `ALL`
– [ ] EOL database versions avoided (check vendor lifecycle)
*(PostgreSQL)*
– [ ] `pg_hba.conf` uses `scram-sha-256`, no `trust`
– [ ] `listen_addresses = ‘localhost’`
– [ ] App role with `CONNECTION LIMIT`
– [ ] Legacy default PUBLIC privileges revoked
– [ ] Slow-query + connection logging on
### Day 2 — DB Tools
– [ ] phpMyAdmin/Adminer/pgAdmin NOT publicly accessible
– [ ] All DB tool access via SSH tunnel (or IP-restricted + Basic Auth + TLS)
– [ ] If public: non-default URL path, strong auth, TLS, IP allowlist
– [ ] Tools updated to latest security patches
### Day 2 — Backups & Monitoring
– [ ] Daily DB dumps (MySQL/PostgreSQL) running
– [ ] Off-site encrypted backup configured (restic/borg/NAS/S3)
– [ ] Restore test performed at least once
– [ ] Monitoring active (disk space, CPU, memory, service health)
– [ ] Uptime/port checks with alerts (email/Discord/Telegram)
– [ ] Logwatch daily summary configured (or equivalent)
### Day 2 — Detection
– [ ] Lynis audit run; score ≥ 70% (ideally 80+)
– [ ] rkhunter baselined and scheduled
– [ ] chkrootkit run
– [ ] AppArmor enforcing for key services (`aa-status`)
– [ ] Cloud provider snapshots enabled (weekly + retention)
– [ ] Cloud provider account has 2FA
### Ongoing — Every Week
– [ ] Check `apt list –upgradable` and update
– [ ] Review fail2ban bans (`fail2ban-client status`)
– [ ] Scan auth log for odd logins (`last -x`, `grep “Accepted” /var/log/auth.log`)
– [ ] Verify backups ran
– [ ] Check disk usage
– [ ] Review error logs for LFI/RCE/brute-force patterns
—
## 11. Troubleshooting & Recovery
### I locked myself out of SSH
1. **Use the provider’s web console** (Vultr, DigitalOcean, Hetzner, AWS EC2 `Serial Console`, etc.).
2. Log in as root via the console (or as the original cloud user).
3. Re-enable password auth temporarily:
“`bash
sudo sed -i ‘s/^PasswordAuthentication no/PasswordAuthentication yes/’ /etc/ssh/sshd_config
sudo systemctl reload ssh
“`
4. Fix whatever broke (key permissions, wrong port, etc.), then re-lock.
### Apache won’t start after a config change
“`bash
# Check the error:
sudo apache2ctl configtest
sudo journalctl -u apache2 -n 50 –no-pager
# Revert the last config change, then:
sudo systemctl start apache2
“`
### MySQL/MariaDB won’t start after config change
“`bash
sudo tail -50 /var/log/mysql/error.log
# Remove the offending line from /etc/mysql/mysql.conf.d/*.cnf
sudo systemctl start mysql
“`
### App can’t connect to the database
“`bash
# Is it listening? (should show 127.0.0.1:3306 or 5432)
sudo ss -tulpn | grep -E “3306|5432”
# Are the credentials right? Test as the app user:
mysql -u app_user -p -h 127.0.0.1 myapp
psql -U app_user -h 127.0.0.1 -d myapp
# Check firewall:
sudo ufw status
# Check AppArmor denials:
sudo dmesg | grep -i “apparmor” | tail -20
“`
### Firewall blocks everything (UFW emergency reset)
> Only do this if you have console access — it **drops all firewall rules**:
“`bash
sudo ufw –force reset
sudo ufw default deny incoming
sudo ufw allow 2222/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw –force enable
“`
### Diagnosing endless “Failed password” attempts
“`bash
# See where attacks come from:
sudo grep “Failed password” /var/log/auth.log | awk ‘{print $(NF-3)}’ | sort | uniq -c | sort -rn | head -20
# Check if the IPs are already banned:
sudo fail2ban-client status sshd
# Manually ban an IP:
sudo fail2ban-client set sshd banip 203.0.113.99
“`
### Recovering a forgotten root DB password (MySQL/MariaDB)
“`bash
# Ubuntu default: root uses socket auth, so:
sudo mysql
# If you need a password for root (not recommended — keep socket auth):
ALTER USER ‘root’@’localhost’ IDENTIFIED WITH auth_socket;
FLUSH PRIVILEGES;
“`
### Recovering PostgreSQL access
“`bash
# You are always the OS ‘postgres’ user with peer auth:
sudo -u postgres psql
“`
—
## Quick Reference — Recommended Config Snippets
### 1. UFW (complete)
“`bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 2222/tcp comment ‘SSH’
sudo ufw allow 80/tcp comment ‘HTTP’
sudo ufw allow 443/tcp comment ‘HTTPS’
sudo ufw –force enable
sudo ufw status verbose
“`
### 2. sshd_config (hardened)
“`ini
Port 2222
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
AllowAgentForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers deployser
LogLevel VERBOSE
“`
### 3. Apache security.conf
“`ini
ServerTokens Prod
ServerSignature Off
TraceEnable Off
“`
### 4. Apache security headers
“`apache
Header always set X-Content-Type-Options “nosniff”
Header always set X-Frame-Options “SAMEORIGIN”
Header always set Referrer-Policy “strict-origin-when-cross-origin”
Header always set Permissions-Policy “geolocation=(), microphone=(), camera=()”
Header always set Cross-Origin-Opener-Policy “same-origin”
“`
### 5. MySQL hardening block
“`ini
[mysqld]
bind-address = 127.0.0.1
secure_file_priv = /var/lib/mysql-files
local-infile = 0
symbolic-links = 0
max_connections = 100
max_connect_errors = 100
connect_timeout = 5
wait_timeout = 600
slow_query_log = 1
long_query_time = 2
“`
### 6. PostgreSQL security essentials
“`ini
# postgresql.conf
listen_addresses = ‘localhost’
max_connections = 100
password_encryption = scram-sha-256
log_connections = on
log_disconnections = on
log_duration = on
log_statement = ‘ddl’
“`
“`ini
# pg_hba.conf (essential lines)
local all postgres peer
local all all scram-sha-256
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
“`
### 7. One-liner server audit
“`bash
sudo apt update && sudo apt upgrade -y && \
sudo lynis audit system –quick | tail -20 && \
sudo systemctl list-units –type=service –state=running && \
sudo ss -tulpn && \
sudo ufw status verbose
“`
—
## Final Words
Security is a **process**, not a state. A hardened server is one you:
1. **Hardened once** — with this checklist.
2. **Monitor daily** — logs, backups, disk, logins.
3. **Patch weekly** — updates, `unattended-upgrades`, container images.
4. **Audit monthly** — rerun Lynis, review users and DB grants.
5. **Test restores quarterly** — your backups, your DR plan, your SSH keys.
The strongest single layer is **least privilege**: fewer users, fewer services, fewer open ports, fewer privileges. Everything else — firewall, fail2ban, mod_security, AppArmor — is defense in depth on top of a fundamentally minimal system.
**If you take away only 5 things:**
1. SSH keys only, no passwords, no root login.
2. UFW deny-all-by-default.
3. Automatic security updates.
4. Localhost-only databases with least-privilege users.
5. Backups off-site, tested, and monitored.
