Back to blog
Harden Any Linux Server in Under 5 Minutes

Harden Any Linux Server in Under 5 Minutes

13 min read

Harden Any Linux Server in 5 Minutes

Every fresh server comes with root login enabled, password auth on, no firewall, no swap. Bots start hitting port 22 within minutes. This script fixes all of that in one run.

The Problem

Every time you spin up a new server — on Hetzner, DigitalOcean, AWS, wherever — it comes completely bare. Root login enabled. Password auth on. No firewall. No swap. No brute-force protection. It takes about 90 seconds for bots to start knocking on port 22.

Most tutorials give you a checklist of 20 commands and expect you to run them manually every time. This script does all of it in one go, interactively asking only what it needs to know, then running without interruption.


What it does

One script. 13 steps. Run it on any fresh Ubuntu 22.04 / 24.04 server and walk away with:


Prerequisites

Run this on a fresh server only. If you have existing services, review the UFW section and add any extra ports you need before running.


How to run it

Step 1 — SSH in as root

bash

ssh root@YOUR_SERVER_IP

Step 2 — Create the script file

bash

nano bootstrap-server.sh

Paste the full script contents, then save: Ctrl+XYEnter

#!/bin/bash
# =============================================================================
# bootstrap-server.sh — Universal server setup & hardening
# Tested on: Ubuntu 22.04 / 24.04 (amd64 + arm64)
# Run as root on a fresh server: bash bootstrap-server.sh
# =============================================================================

set -euo pipefail

# ─────────────────────────────────────────────
# Colors & helpers
# ─────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

log()     { echo -e "${GREEN}[+]${NC} $1"; }
warn()    { echo -e "${YELLOW}[!]${NC} $1"; }
error()   { echo -e "${RED}[x]${NC} $1"; exit 1; }
info()    { echo -e "${BLUE}[i]${NC} $1"; }
section() { echo -e "\n${CYAN}${BOLD}━━━  $1  ━━━${NC}"; }
ask()     { echo -e "${YELLOW}[?]${NC} $1"; }

# ─────────────────────────────────────────────
# Must run as root
# ─────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
  error "Run as root: bash bootstrap-server.sh"
fi

# ─────────────────────────────────────────────
# Banner
# ─────────────────────────────────────────────
clear
echo -e "${CYAN}${BOLD}"
cat << 'EOF'
  ██████╗  ██████╗  ██████╗ ████████╗███████╗████████╗██████╗  █████╗ ██████╗ 
  ██╔══██╗██╔═══██╗██╔═══██╗╚══██╔══╝██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██╔══██╗
  ██████╔╝██║   ██║██║   ██║   ██║   ███████╗   ██║   ██████╔╝███████║██████╔╝
  ██╔══██╗██║   ██║██║   ██║   ██║   ╚════██║   ██║   ██╔══██╗██╔══██║██╔═══╝ 
  ██████╔╝╚██████╔╝╚██████╔╝   ██║   ███████║   ██║   ██║  ██║██║  ██║██║     
  ╚═════╝  ╚═════╝  ╚═════╝    ╚═╝   ╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝     
EOF
echo -e "${NC}"
echo -e "${BOLD}  Universal Server Bootstrap & Hardening Script${NC}"
echo -e "  by Maqbool Thoufeeq Tharayil / maqboolthoufeeq.com\n"

# ─────────────────────────────────────────────
# STEP 0 — Collect user input upfront
# ─────────────────────────────────────────────
section "0. Configuration"

# --- Dev username ---
ask "Enter the non-root username to create (default: dev):"
read -r INPUT_USER
DEV_USER="${INPUT_USER:-dev}"
log "Username: $DEV_USER"

# --- SSH public key ---
echo ""
info "Looking for SSH public keys on this machine..."

FOUND_KEY=""

# Check if an authorized_keys already exists for root (rescue mode scenario)
if [[ -f /root/.ssh/authorized_keys ]] && [[ -s /root/.ssh/authorized_keys ]]; then
  FOUND_KEY=$(head -n1 /root/.ssh/authorized_keys)
  info "Found existing key in /root/.ssh/authorized_keys:"
  echo -e "  ${CYAN}${FOUND_KEY:0:60}...${NC}"
  ask "Use this key for the new user '$DEV_USER'? (Y/n):"
  read -r USE_EXISTING
  if [[ "${USE_EXISTING,,}" == "n" ]]; then
    FOUND_KEY=""
  fi
fi

if [[ -z "$FOUND_KEY" ]]; then
  echo ""
  warn "No SSH key found. Please paste your public key below."
  info "Get it on your Mac/Linux with: cat ~/.ssh/id_ed25519.pub"
  info "Or: cat ~/.ssh/id_rsa.pub"
  echo ""
  ask "Paste your SSH public key (starts with ssh-ed25519 or ssh-rsa):"
  while true; do
    read -r FOUND_KEY
    if [[ "$FOUND_KEY" == ssh-* ]]; then
      log "Key accepted."
      break
    else
      warn "That doesn't look like a valid SSH public key. Try again:"
    fi
  done
fi

SSH_PUB_KEY="$FOUND_KEY"

# --- Swap size ---
echo ""
TOTAL_RAM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}')
TOTAL_RAM_GB=$(( TOTAL_RAM_KB / 1024 / 1024 ))
info "Detected RAM: ${TOTAL_RAM_GB}GB"
ask "Swap size in GB? (default: 8):"
read -r INPUT_SWAP
SWAP_SIZE="${INPUT_SWAP:-8}"
log "Swap size: ${SWAP_SIZE}GB"

# --- Hostname ---
echo ""
CURRENT_HOST=$(hostname)
ask "Set server hostname? (current: $CURRENT_HOST, press Enter to keep):"
read -r INPUT_HOST
NEW_HOSTNAME="${INPUT_HOST:-$CURRENT_HOST}"

# --- Extra open ports ---
echo ""
info "Default open ports: 22 (SSH), 80 (HTTP), 443 (HTTPS)"
ask "Any extra ports to open? (e.g: 8200 5432, or press Enter to skip):"
read -r EXTRA_PORTS

# --- Docker ---
echo ""
ask "Install Docker? (Y/n):"
read -r INSTALL_DOCKER
INSTALL_DOCKER="${INSTALL_DOCKER:-Y}"

# ─────────────────────────────────────────────
# Confirm before proceeding
# ─────────────────────────────────────────────
echo ""
echo -e "${BOLD}━━━  Review Configuration  ━━━${NC}"
echo -e "  User:           ${GREEN}$DEV_USER${NC}"
echo -e "  SSH key:        ${GREEN}${SSH_PUB_KEY:0:50}...${NC}"
echo -e "  Swap:           ${GREEN}${SWAP_SIZE}GB${NC}"
echo -e "  Hostname:       ${GREEN}$NEW_HOSTNAME${NC}"
echo -e "  Extra ports:    ${GREEN}${EXTRA_PORTS:-none}${NC}"
echo -e "  Install Docker: ${GREEN}$INSTALL_DOCKER${NC}"
echo ""
ask "Proceed? (Y/n):"
read -r CONFIRM
if [[ "${CONFIRM,,}" == "n" ]]; then
  warn "Aborted."
  exit 0
fi

# ─────────────────────────────────────────────
# STEP 1 — System update
# ─────────────────────────────────────────────
section "1. System Update"

export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get upgrade -y -q
apt-get install -y -q \
  curl wget git unzip \
  ufw fail2ban \
  htop vim nano \
  net-tools dnsutils \
  ca-certificates gnupg \
  unattended-upgrades \
  apt-transport-https \
  software-properties-common

log "System updated and base packages installed"

# ─────────────────────────────────────────────
# STEP 2 — Hostname
# ─────────────────────────────────────────────
section "2. Hostname"

if [[ "$NEW_HOSTNAME" != "$CURRENT_HOST" ]]; then
  hostnamectl set-hostname "$NEW_HOSTNAME"
  sed -i "s/$CURRENT_HOST/$NEW_HOSTNAME/g" /etc/hosts 2>/dev/null || true
  log "Hostname set to: $NEW_HOSTNAME"
else
  log "Hostname unchanged: $CURRENT_HOST"
fi

# ─────────────────────────────────────────────
# STEP 3 — Create dev user
# ─────────────────────────────────────────────
section "3. User Setup"

if id "$DEV_USER" &>/dev/null; then
  warn "User '$DEV_USER' already exists — skipping creation"
else
  useradd -m -s /bin/bash "$DEV_USER"
  log "Created user: $DEV_USER"
fi

# Add to sudo group
usermod -aG sudo "$DEV_USER"
log "Added $DEV_USER to sudo group"

# Set a random strong password (user can change later)
RANDOM_PASS=$(openssl rand -base64 16)
echo "$DEV_USER:$RANDOM_PASS" | chpasswd
log "Set random password for $DEV_USER (save this!)"
echo ""
echo -e "  ${YELLOW}${BOLD}Password for $DEV_USER: $RANDOM_PASS${NC}"
echo -e "  ${YELLOW}Save this now — it won't be shown again.${NC}"
echo ""

# Add SSH public key
USER_HOME=$(eval echo "~$DEV_USER")
mkdir -p "$USER_HOME/.ssh"
echo "$SSH_PUB_KEY" > "$USER_HOME/.ssh/authorized_keys"
chmod 700 "$USER_HOME/.ssh"
chmod 600 "$USER_HOME/.ssh/authorized_keys"
chown -R "$DEV_USER:$DEV_USER" "$USER_HOME/.ssh"
log "SSH key added for $DEV_USER"

# Sudo timeout
echo "Defaults timestamp_timeout=5" > /etc/sudoers.d/timeout
chmod 440 /etc/sudoers.d/timeout
log "Sudo timeout: 5 minutes"

# Lock root password
passwd -l root
log "Root password locked"

# ─────────────────────────────────────────────
# STEP 4 — SSH Hardening
# ─────────────────────────────────────────────
section "4. SSH Hardening"

SSHD=/etc/ssh/sshd_config
cp $SSHD "${SSHD}.bak.$(date +%Y%m%d%H%M%S)"
log "Backed up sshd_config"

declare -A SSH_SETTINGS=(
  [PermitRootLogin]="no"
  [PasswordAuthentication]="no"
  [PubkeyAuthentication]="yes"
  [AuthenticationMethods]="publickey"
  [PermitEmptyPasswords]="no"
  [X11Forwarding]="no"
  [MaxAuthTries]="3"
  [LoginGraceTime]="30"
  [ClientAliveInterval]="300"
  [ClientAliveCountMax]="2"
  [AllowUsers]="$DEV_USER"
  [Protocol]="2"
  [UseDNS]="no"
)

for key in "${!SSH_SETTINGS[@]}"; do
  val="${SSH_SETTINGS[$key]}"
  if grep -qE "^#?[[:space:]]*${key}" $SSHD; then
    sed -i "s|^#\?[[:space:]]*${key}.*|${key} ${val}|" $SSHD
  else
    echo "${key} ${val}" >> $SSHD
  fi
  log "SSH: $key = $val"
done

# Fix any override files in sshd_config.d/
for f in /etc/ssh/sshd_config.d/*.conf; do
  [[ -f "$f" ]] || continue
  if grep -q "PasswordAuthentication yes" "$f" 2>/dev/null; then
    sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/' "$f"
    warn "Fixed PasswordAuthentication in $f"
  fi
done

sshd -t && systemctl restart ssh
log "SSH restarted successfully"

# ─────────────────────────────────────────────
# STEP 5 — Firewall (UFW)
# ─────────────────────────────────────────────
section "5. Firewall (UFW)"

ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp   comment "SSH"
ufw allow 80/tcp   comment "HTTP"
ufw allow 443/tcp  comment "HTTPS"

# Extra ports
if [[ -n "$EXTRA_PORTS" ]]; then
  for port in $EXTRA_PORTS; do
    ufw allow "${port}/tcp" comment "Custom"
    log "Opened port: $port/tcp"
  done
fi

ufw --force enable
log "UFW enabled"
ufw status verbose

# ─────────────────────────────────────────────
# STEP 6 — Docker (optional)
# ─────────────────────────────────────────────
section "6. Docker"

if [[ "${INSTALL_DOCKER,,}" != "n" ]]; then
  if command -v docker &>/dev/null; then
    warn "Docker already installed — skipping install"
  else
    log "Installing Docker..."
    curl -fsSL https://get.docker.com | bash
    log "Docker installed"
  fi

  # Add dev user to docker group
  usermod -aG docker "$DEV_USER"
  log "Added $DEV_USER to docker group"

  # Fix Docker bypassing UFW (iptables: false)
  mkdir -p /etc/docker
  if [[ -f /etc/docker/daemon.json ]]; then
    python3 - <<'PYEOF'
import json
with open('/etc/docker/daemon.json', 'r') as f:
    cfg = json.load(f)
cfg['iptables'] = False
with open('/etc/docker/daemon.json', 'w') as f:
    json.dump(cfg, f, indent=2)
PYEOF
  else
    echo '{"iptables": false}' > /etc/docker/daemon.json
  fi

  systemctl restart docker
  log "Docker UFW bypass fixed (iptables: false)"
else
  log "Docker installation skipped"
fi

# ─────────────────────────────────────────────
# STEP 7 — Fail2Ban
# ─────────────────────────────────────────────
section "7. Fail2Ban"

cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime  = 24h
findtime = 10m
maxretry = 3
backend  = systemd

[sshd]
enabled  = true
port     = ssh
maxretry = 3
bantime  = 24h
EOF

systemctl enable fail2ban
systemctl restart fail2ban
log "Fail2Ban: SSH — 3 attempts = 24h ban"

# ─────────────────────────────────────────────
# STEP 8 — Swap
# ─────────────────────────────────────────────
section "8. Swap (${SWAP_SIZE}GB)"

if swapon --show | grep -q '/swapfile'; then
  warn "Swapfile already active — skipping"
else
  fallocate -l "${SWAP_SIZE}G" /swapfile
  chmod 600 /swapfile
  mkswap /swapfile
  swapon /swapfile
  echo '/swapfile none swap sw 0 0' >> /etc/fstab
  log "Swapfile created and activated: ${SWAP_SIZE}GB"
fi

# Tune swappiness
sysctl vm.swappiness=10
grep -q 'vm.swappiness' /etc/sysctl.d/99-harden.conf 2>/dev/null || \
  echo 'vm.swappiness=10' >> /etc/sysctl.d/99-harden.conf

# Tune cache pressure
sysctl vm.vfs_cache_pressure=50
echo 'vm.vfs_cache_pressure=50' >> /etc/sysctl.d/99-harden.conf

free -h
log "Swap active"

# ─────────────────────────────────────────────
# STEP 9 — Kernel hardening (sysctl)
# ─────────────────────────────────────────────
section "9. Kernel Hardening"

cat > /etc/sysctl.d/99-harden.conf << EOF
# Swap tuning
vm.swappiness=10
vm.vfs_cache_pressure=50

# Disable IP forwarding
net.ipv4.ip_forward = 0

# SYN flood protection
net.ipv4.tcp_syncookies = 1

# Ignore ICMP broadcasts
net.ipv4.icmp_echo_ignore_broadcasts = 1

# Ignore bogus ICMP errors
net.ipv4.icmp_ignore_bogus_error_responses = 1

# Disable source routing
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0

# Disable ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv6.conf.all.accept_redirects = 0

# Prevent IP spoofing
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# Log martian packets
net.ipv4.conf.all.log_martians = 1

# Protect against time-wait assassination
net.ipv4.tcp_rfc1337 = 1

# Disable IPv6 if not needed (comment out if you use IPv6)
# net.ipv6.conf.all.disable_ipv6 = 1
EOF

sysctl --system > /dev/null 2>&1
log "Kernel hardening applied"

# ─────────────────────────────────────────────
# STEP 10 — Automatic security updates
# ─────────────────────────────────────────────
section "10. Automatic Security Updates"

cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF'
Unattended-Upgrade::Allowed-Origins {
  "${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF

cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
EOF

systemctl enable unattended-upgrades
systemctl restart unattended-upgrades
log "Automatic security updates configured"

# ─────────────────────────────────────────────
# STEP 11 — Timezone & NTP
# ─────────────────────────────────────────────
section "11. Timezone & NTP"

timedatectl set-timezone UTC
systemctl enable systemd-timesyncd
systemctl start systemd-timesyncd
log "Timezone: UTC, NTP: enabled"

# ─────────────────────────────────────────────
# STEP 12 — Useful aliases & shell setup for dev user
# ─────────────────────────────────────────────
section "12. Shell Setup"

cat >> "$USER_HOME/.bashrc" << 'EOF'

# ── Bootstrap additions ──────────────────────
alias ll='ls -alF'
alias la='ls -A'
alias l='ls -CF'
alias ..='cd ..'
alias ...='cd ../..'
alias df='df -h'
alias du='du -h'
alias free='free -h'
alias ports='ss -tulnp'
alias myip='curl -s ifconfig.me'
alias dps='docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"'
alias dlogs='docker logs -f'
alias update='sudo apt-get update && sudo apt-get upgrade -y'

# Colored prompt
export PS1='\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
EOF

chown "$DEV_USER:$DEV_USER" "$USER_HOME/.bashrc"
log "Shell aliases and prompt configured for $DEV_USER"

# ─────────────────────────────────────────────
# STEP 13 — Secure shared memory
# ─────────────────────────────────────────────
section "13. Secure Shared Memory"

if ! grep -q 'tmpfs /run/shm' /etc/fstab; then
  echo 'tmpfs /run/shm tmpfs defaults,noexec,nosuid 0 0' >> /etc/fstab
  log "Shared memory secured"
else
  log "Shared memory already secured"
fi

# ─────────────────────────────────────────────
# Final Summary
# ─────────────────────────────────────────────
section "✓ Bootstrap Complete"

SERVER_IP=$(hostname -I | awk '{print $1}')

echo ""
echo -e "${GREEN}${BOLD}  Everything is set up. Here's your server:${NC}"
echo ""
echo -e "  ${BOLD}Access${NC}"
echo -e "  ├── SSH:              ssh ${DEV_USER}@${SERVER_IP}"
echo -e "  ├── User:             ${DEV_USER}"
echo -e "  ├── Password:         ${YELLOW}${RANDOM_PASS}${NC} (save this!)"
echo -e "  └── Auth:             SSH key only (password auth disabled)"
echo ""
echo -e "  ${BOLD}Security${NC}"
echo -e "  ├── Root login:       DISABLED"
echo -e "  ├── Password auth:    DISABLED"
echo -e "  ├── UFW firewall:     ON (22, 80, 443${EXTRA_PORTS:+, $EXTRA_PORTS})"
echo -e "  ├── Fail2Ban:         ON (3 attempts = 24h ban)"
echo -e "  ├── Auto updates:     ON (security only)"
echo -e "  └── Kernel hardening: ON"
echo ""
echo -e "  ${BOLD}System${NC}"
echo -e "  ├── Hostname:         $(hostname)"
echo -e "  ├── Swap:             ${SWAP_SIZE}GB (swappiness=10)"
echo -e "  ├── Timezone:         UTC"
if [[ "${INSTALL_DOCKER,,}" != "n" ]]; then
echo -e "  └── Docker:           installed (UFW bypass fixed)"
else
echo -e "  └── Docker:           not installed"
fi
echo ""
echo -e "  ${YELLOW}${BOLD}⚠  Test SSH in a NEW terminal before closing this session:${NC}"
echo -e "  ${CYAN}   ssh ${DEV_USER}@${SERVER_IP}${NC}"
echo ""

Step 3 — Make it executable and run

bash

chmod +x bootstrap-server.sh
bash bootstrap-server.sh

That's it. The script asks a few questions upfront, then runs without interruption.


What it asks you

The script collects everything it needs at the start, then runs fully automated:

Prompt

Default

Example

Username

dev

maqbool

SSH public key

auto-detected from /root/.ssh/authorized_keys

paste if not found

Swap size (GB)

8

4, 16

Hostname

current hostname

vault, worker-01

Extra ports

none

8200 5432

Install Docker

yes

n to skip


The Docker + UFW problem

This one catches a lot of people off guard. Docker bypasses UFW by default by writing its own iptables rules directly. A container bound to 0.0.0.0:5432 is reachable from the internet even if UFW says port 5432 is closed.

The script fixes this by setting "iptables": false in /etc/docker/daemon.json. After this, always bind container ports to localhost:

yaml

# ✗ Exposed to the world — bypasses UFW
- "5432:5432"

# ✓ Localhost only — respects UFW
- "127.0.0.1:5432:5432"

After it runs

The script prints a full summary at the end:

━━━  ✓ Bootstrap Complete  ━━━

  Access
  ├── SSH:    ssh dev@<server-ip>
  ├── User:   dev
  ├── Pass:   Xk9mPqR2aB3...   ← save this
  └── Auth:   SSH key only

  Security
  ├── Root login:    DISABLED
  ├── Password auth: DISABLED
  ├── UFW:           ON  (22, 80, 443)
  ├── Fail2Ban:      ON  (3 attempts = 24h ban)
  └── Auto updates:  ON

  System
  ├── Swap:     8GB (swappiness=10)
  ├── Timezone: UTC
  └── Docker:   installed (UFW bypass fixed)

⚠️ Always test SSH in a new terminal before closing your current session. The script disables root login and password auth — if you close without testing, you'll need rescue mode to get back in.


After the script — what's next


Built by Maqbool Thoufeeq Tharayil - maqboolthoufeeq.com Tested on Hetzner CAX11, CX22, DigitalOcean · Ubuntu 22.04 / 24.04 · MIT License

Was this helpful?

Give it a zap to let me know.

Or share it with someone

Read similar blogs

Importance of Cybersecurity in the Era of AI

Importance of Cybersecurity in the Era of AI

As AI moves from the lab into the core of critical systems, security has become inseparable from the technology itself. A practical look at the threats facing AI in 2026—adversarial attacks, data poisoning, prompt injection—and how to defend against them.

5 min read
HashiCorp Vault Explained: Why Modern Applications Need Secrets Management

HashiCorp Vault Explained: Why Modern Applications Need Secrets Management

Every modern application relies on secrets. Database passwords, API keys, SSH keys, TLS certificates, cloud credentials, Kubernetes tokens—these sensitive pieces of information enable applications and engineers to access critical systems. Yet despite their importance, secrets are often managed poorly. They end up scattered across source code repositories, CI/CD pipelines, engineers' laptops, configuration files, and internal documentation pages. This phenomenon is known as Secret Sprawl, and it is one of the most common security risks in modern software development. This is exactly the problem that HashiCorp Vault was designed to solve.

6 min read