Skip to content

Instantly share code, notes, and snippets.

@lukedunsmoto
Created October 8, 2025 20:27
Show Gist options
  • Select an option

  • Save lukedunsmoto/8ac78f3065372b47a82bb2f5cd33264a to your computer and use it in GitHub Desktop.

Select an option

Save lukedunsmoto/8ac78f3065372b47a82bb2f5cd33264a to your computer and use it in GitHub Desktop.
A shell script I wrote to bootstrap and harden my VPS before deploying Dokploy. Simple, opinionated, and idempotent — my starting point for the ‘Hey Lorna’ stack.
# Ubuntu 24.04 VPS Baseline (Hey Lorna Build v38)
#
# Purpose: Hardens and prepares a fresh server with UFW, Fail2Ban, Docker CE,
# key-only SSH, and unattended upgrades. Designed for Dokploy deployments
# but generic enough for most small servers.
#
# Tested: Fasthosts VPS (2 vCPU / 4GB RAM)
#!/usr/bin/env bash
set -euo pipefail
# === CONFIG: EDIT THESE BEFORE RUNNING ===
ADMIN_USER="ENTER_YOUR_USERNAME" # your sudo user
ADMIN_SSH_KEY="ENTER_YOUR_KEY" # paste your ssh-ed25519 / rsa public key here (single line)
NEW_HOSTNAME="ENTER_YOUR_HOSTNAME" # machine hostname
TIMEZONE="Europe/London" # system timezone
SWAP_SIZE_GB="2" # swap size in GiB
# === Helpers ===
log(){ printf "\n[%s] %s\n" "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; }
file_has_line(){ [ -f "$1" ] && grep -Fxq "$2" "$1"; }
log "Baseline start (Ubuntu $(. /etc/os-release; echo "$VERSION") )"
# 1) Timezone & hostname
log "Setting timezone -> ${TIMEZONE}"
timedatectl set-timezone "${TIMEZONE}" || true
log "Setting hostname -> ${NEW_HOSTNAME}"
hostnamectl set-hostname "${NEW_HOSTNAME}"
# 2) Apt refresh & base packages
log "Updating apt & installing base packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get dist-upgrade -y
apt-get install -y --no-install-recommends \
ca-certificates apt-transport-https gnupg \
ufw fail2ban unattended-upgrades \
net-tools curl wget jq git tmux htop unzip zip \
software-properties-common
# 3) Create swap if not exists
if ! swapon --show | grep -q '^'; then
log "Creating ${SWAP_SIZE_GB}GiB swapfile"
fallocate -l ${SWAP_SIZE_GB}G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=$((SWAP_SIZE_GB*1024))
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
if ! grep -q "^/swapfile" /etc/fstab; then
echo "/swapfile none swap sw 0 0" >> /etc/fstab
fi
else
log "Swap already present; skipping"
fi
# 4) UFW (22/80/443) + default deny incoming
log "Configuring UFW"
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
# 5) Fail2Ban: minimal sshd jail
log "Configuring Fail2Ban"
mkdir -p /etc/fail2ban
cat >/etc/fail2ban/jail.local <<'JAIL'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
JAIL
systemctl enable --now fail2ban
# 6) Unattended upgrades (security)
log "Enabling unattended security upgrades"
dpkg-reconfigure -f noninteractive unattended-upgrades || true
# Ensure periodic runs
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'AUTO'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::Unattended-Upgrade "1";
AUTO
# 7) Small sysctl bump useful for some apps (Elasticsearch, etc.)
log "Applying vm.max_map_count=262144"
if ! grep -q '^vm.max_map_count=262144' /etc/sysctl.conf; then
echo 'vm.max_map_count=262144' >> /etc/sysctl.conf
fi
sysctl -p >/dev/null || true
# 8) Admin user + SSH hardening (only lock root if a key is set)
if id -u "${ADMIN_USER}" >/dev/null 2>&1; then
log "User ${ADMIN_USER} already exists; skipping creation"
else
log "Creating admin user: ${ADMIN_USER}"
adduser --disabled-password --gecos "" "${ADMIN_USER}"
usermod -aG sudo "${ADMIN_USER}"
fi
if [ -n "${ADMIN_SSH_KEY}" ]; then
log "Installing SSH key for ${ADMIN_USER}"
install -d -m 700 /home/${ADMIN_USER}/.ssh
touch /home/${ADMIN_USER}/.ssh/authorized_keys
chmod 600 /home/${ADMIN_USER}/.ssh/authorized_keys
if ! file_has_line "/home/${ADMIN_USER}/.ssh/authorized_keys" "${ADMIN_SSH_KEY}"; then
echo "${ADMIN_SSH_KEY}" >> /home/${ADMIN_USER}/.ssh/authorized_keys
fi
chown -R ${ADMIN_USER}:${ADMIN_USER} /home/${ADMIN_USER}/.ssh
log "Hardening sshd (disable root & password auth)"
sed -ri 's/^\s*#?\s*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -ri 's/^\s*#?\s*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart ssh || systemctl restart sshd || true
else
log "No ADMIN_SSH_KEY provided — leaving root/password SSH as-is to avoid lockout."
log ">>> IMPORTANT: Add your key and harden sshd after first login."
fi
# 9) Docker CE + compose plugin (required for Dokploy)
if ! command -v docker >/dev/null 2>&1; then
log "Installing Docker CE & compose plugin"
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
# Add admin to docker group (will take effect on next login)
usermod -aG docker "${ADMIN_USER}" || true
else
log "Docker already installed; skipping"
fi
# 10) Quick MOTD
cat >/etc/motd <<MOTD
Welcome to ${NEW_HOSTNAME}
- Timezone: $(timedatectl | awk -F': ' '/Time zone/{print $2}')
- Docker: $(docker --version 2>/dev/null || echo not installed)
- UFW: $(ufw status | head -n1)
- Fail2Ban: $(systemctl is-active fail2ban || true)
MOTD
log "Baseline complete ✅ — safe to SSH in as: ${ADMIN_USER}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment