Created
October 8, 2025 20:27
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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