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