TharayilMaqbool Thoufeeq
  • Projects
  • Blogs
  • Contact
  • Hub
Admin
Back to blog
Harden Any Linux Server in Under 5 Minutes

Harden Any Linux Server in Under 5 Minutes

May 24, 202515 min read
#cybersecurity#devops#linux
Zap if this helped

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:

  • SSH hardened — root login disabled, key-only auth, only your user allowed in

  • UFW firewall — only ports 22, 80, 443 open by default

  • Fail2Ban — 3 failed SSH attempts = 24-hour ban

  • Docker — installed with the UFW bypass fixed

  • 8 GB swap — configurable, with swappiness=10

  • Kernel hardening — SYN flood protection, anti-spoofing, ICMP hardening

  • Automatic security updates — unattended-upgrades, security patches only

  • Non-root user — created with your SSH key, added to sudo

  • Shell aliases — dps, ports, myip, dlogs and more


Prerequisites

  • A fresh Ubuntu 22.04 or 24.04 server (works on both amd64 and arm64)

  • Root SSH access

  • Your SSH public key ready (cat ~/.ssh/id_ed25519.pub on your local machine)

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+X → Y → Enter

#!/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=""

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"
  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}"

# --- Passwordless sudo ---
echo ""
ask "Grant passwordless sudo to $DEV_USER? (y/N):"
read -r NOPASSWD_SUDO
NOPASSWD_SUDO="${NOPASSWD_SUDO:-N}"

# ─────────────────────────────────────────────
# 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 -e "  Passwordless sudo: ${GREEN}$NOPASSWD_SUDO${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 \
  nginx \
  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"

# Passwordless sudo
if [[ "${NOPASSWD_SUDO,,}" == "y" ]]; then
  echo "$DEV_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$DEV_USER
  chmod 440 /etc/sudoers.d/$DEV_USER
  log "Passwordless sudo granted to $DEV_USER"
else
  # Remove any old NOPASSWD entry if exists
  rm -f /etc/sudoers.d/$DEV_USER
  log "Sudo requires password for $DEV_USER (recommended)"
fi

# Set a random strong password
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 (only if not passwordless)
if [[ "${NOPASSWD_SUDO,,}" != "y" ]]; then
  echo "Defaults timestamp_timeout=5" > /etc/sudoers.d/timeout
  chmod 440 /etc/sudoers.d/timeout
  log "Sudo timeout: 5 minutes"
fi

# 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"

  # ── Docker daemon: log rotation + correct UFW fix ──
  #
  # We do NOT use "iptables: false" — that breaks container
  # networking entirely (no internet, no DNS).
  #
  # Instead we keep iptables enabled and add a NAT masquerade
  # rule to UFW's before.rules so Docker traffic is routed
  # correctly while UFW still controls inbound access.
  # Containers should always bind to 127.0.0.1 for private ports.

  mkdir -p /etc/docker
  cat > /etc/docker/daemon.json << 'DOCKEREOF'
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
DOCKEREOF
  log "Docker log rotation configured (10MB x 3 files)"

  # Add Docker masquerade rule to UFW before.rules if not already there
  if ! grep -q 'DOCKER-UFW-FIX' /etc/ufw/before.rules; then
    sed -i '1s/^/# DOCKER-UFW-FIX\n*nat\n:POSTROUTING ACCEPT [0:0]\n-A POSTROUTING ! -o docker0 -s 172.17.0.0\/16 -j MASQUERADE\nCOMMIT\n\n/' /etc/ufw/before.rules
    log "UFW/Docker NAT masquerade rule added"
  else
    log "UFW/Docker NAT rule already present — skipping"
  fi

  # Enable IP forwarding (required for Docker networking)
  sed -i 's|^#\?net/ipv4/ip_forward.*|net/ipv4/ip_forward=1|' /etc/ufw/sysctl.conf 2>/dev/null || true
  echo "net.ipv4.ip_forward = 1" > /etc/sysctl.d/99-docker-forward.conf
  sysctl -w net.ipv4.ip_forward=1 > /dev/null

  ufw reload
  systemctl restart docker
  log "Docker networking working (iptables enabled, UFW controls inbound)"
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

sysctl vm.swappiness=10
sysctl vm.vfs_cache_pressure=50
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

# IP forwarding — enabled for Docker (controlled by UFW)
net.ipv4.ip_forward = 1

# 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
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

if systemctl list-unit-files | grep -q 'systemd-timesyncd'; then
  systemctl enable systemd-timesyncd 2>/dev/null || true
  systemctl start systemd-timesyncd 2>/dev/null || true
  log "NTP: systemd-timesyncd enabled"
elif systemctl list-unit-files | grep -q 'chrony'; then
  systemctl enable chrony
  systemctl start chrony
  log "NTP: chrony enabled"
else
  apt-get install -y -q chrony
  systemctl enable chrony
  systemctl start chrony
  log "NTP: chrony installed and enabled"
fi

log "Timezone: UTC"

# ─────────────────────────────────────────────
# STEP 12 — nginx
# ─────────────────────────────────────────────
section "12. nginx"

systemctl enable nginx
systemctl start nginx 2>/dev/null || true

# Remove default site — it conflicts with custom configs
if [[ -f /etc/nginx/sites-enabled/default ]]; then
  rm -f /etc/nginx/sites-enabled/default
  log "Removed nginx default site"
fi

nginx -t && systemctl reload nginx
log "nginx running and default site removed"

# ─────────────────────────────────────────────
# STEP 13 — Shell setup for dev user
# ─────────────────────────────────────────────
section "13. Shell Setup"

# Avoid duplicate entries if script is re-run
if ! grep -q 'Bootstrap additions' "$USER_HOME/.bashrc" 2>/dev/null; then
  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
  log "Shell aliases added for $DEV_USER"
else
  log "Shell aliases already present — skipping"
fi

chown "$DEV_USER:$DEV_USER" "$USER_HOME/.bashrc"

# ─────────────────────────────────────────────
# STEP 14 — Secure shared memory
# ─────────────────────────────────────────────
section "14. 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"
echo -e "  ├── nginx:            installed (default site removed)"
if [[ "${INSTALL_DOCKER,,}" != "n" ]]; then
echo -e "  ├── Docker:           installed (log rotation + UFW NAT fix)"
echo -e "  └── Sudo:             ${NOPASSWD_SUDO^^} passwordless"
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

  • Rotate your secrets. Move API keys and passwords into chmod 600 env files, or a secrets manager.

  • Check Fail2Ban. See who's been banned: sudo fail2ban-client status sshd. You'll be surprised.

  • Set up backups. If you're running PostgreSQL, add pgBackRest with an S3 backend.


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.

Zap if this helped

Or share it with someone

← Previous postUV Package Manager: The Comprehensive GuideNext post →🚀Claude Sub-Agents — The npm for AI developers! (sub-agents CLI)

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.

May 25, 20265 min read
#ai#cybersecurity
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.

Mar 16, 20256 min read
#cybersecurity
Android Board Based Intelligent Car Anti-Theft System Through Face Recognition Using GSM and GPS

Android Board Based Intelligent Car Anti-Theft System Through Face Recognition Using GSM and GPS

A low-cost smart car security system that uses face recognition (PCA/eigenfaces) on an Android board, combined with GSM and GPS, to detect unauthorized drivers, alert the owner, locate the vehicle, and remotely stop the engine.

Mar 27, 20175 min read
#cybersecurity#security