Share on TwitterShare on PinterestShare on LinkedinCopy LinkLink is Copied!Share on Email
When it comes to VPS security, having a repeatable, routine way of ensuring your server stays safe is key. I shared a Gist on Github a little while back that allows users with a new virtual private server to set it up quickly and safely.
But first of all, you might be thinking, ‘Who’s Lorna?’ I’ve written before about the argument for naming your machines, and this is how Lorna was born.‘Hey Lorna’ is the name of my Life OS, which lives on my VPS.You can read about that here.
When I spin up a fresh VPS (Virtual Private Server), I want security, convenience, and repeatability — without over-engineering. This shell-script is my go-to for initializing a server: set time-zone, install essentials, lock it down a bit, enable Docker for deployments. It’s simple, opinionated, and safe enough for many small projects.
Here’s a breakdown of what the script does, why it matters — and how you can run it to start from a secure baseline on Ubuntu. For reference, I developed this script on Ubuntu version 24.04.
Why This Matters
Unsecured servers get probed within minutes. Simple misconfigurations — unused ports, password SSH, outdated packages — can make a VPS an easy target for bots or attackers. Taking a little time up front to harden your server drastically reduces your risk of trouble down the line.
This script implements many of the recommended practices in the Linux sysadmin world: firewall, SSH-key authentication, automated updates, minimum exposed ports, swap for stability, and Docker for flexible deployments.
What the Script Does (Step-by-Step)
Set timezone & hostname — useful for logs and clarity when you SSH in.
System update & install base packages — ensures you start with latest security patches; installs useful tools: shell utilities, networking tools, package tools.
Swapfile creation (if none present) — ensures the server has swap, which helps stability on small VPS plans.
Firewall with ufw:
resets previous firewall rules, then sets defaults
blocks all incoming by default, allows outgoing
explicitly permits ports 22 (SSH), 80 (HTTP), 443 (HTTPS) — ideal for a typical web server or deployment environment.
Install & enable fail2ban with a basic SSH jail — helps block brute-force login attempts automatically.
Enable unattended security upgrades — ensures that security updates get applied without manual intervention.
Tweak sysctl for vm.max_map_count — sometimes needed for containerized workloads (e.g. Elasticsearch), but harmless for most servers.
SSH hardening and admin user creation:
creates a non-root “admin” user with sudo privileges
installs your public SSH key for that user
disables root login and password-based SSH (only if key is provided) — forces key-only login.
Install docker-ce and docker-compose plugin — prepares the server for container deployments. Adds the admin user to the docker group for ease-of-use.
Basic MOTD / summary output — gives a quick overview of the server’s status (timezone, docker status, UFW status, Fail2Ban) when you SSH in.
At the end: you have a hardened, minimal server ready to receive deployments — with SSH locked down, updates automated, resources in place, Docker ready.
How to Use It
The full script is below, followed by usage instructions. You can also check out the original Github Gist here.
# 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}"
Follow the steps below to use the script – it runs fairly quickly, but depending on the specs of your VPS some steps may take a little while.
Clone or copy the script to your local machine.
Edit the top of the script to replace placeholders:
ADMIN_USER → your desired non-root username
ADMIN_SSH_KEY → your public SSH key (one line)
NEW_HOSTNAME → a name for your server (e.g. “my-vps-1”)
TIMEZONE → as appropriate (default is Europe/London)
SWAP_SIZE_GB → adjust based on your server’s RAM / expected load
Upload or paste the script to your VPS (e.g. via scp or copy/paste).
Run it with root (or via sudo).
Once it finishes, log out and log back in as your new admin user via SSH key — confirm all is working.
Note that your for step 5, your login from now on will be ADMIN_USER@NEW_HOSTNAME – replace with your values.
What This Doesn’t Do (and Options to Consider)
This baseline is safe and straightforward — but not hardcore. Depending on your threat model or workload you may want to go further:
Change SSH port (from 22 → non-standard) to avoid some bot-noise.
Lock down or disable IPv6 (if not used), tweak more kernel / network sysctls for stricter security.
Remove unnecessary services or packages — anything not needed (mail, old daemons, compilers, etc.) to reduce attack surface.
Add file-integrity monitoring, or intrusion detection (e.g. AIDE, Tripwire, or external monitoring) for more advanced security.
Backups or snapshotting — the script doesn’t handle backups. Important for production. I use Dokploy as my PaaS – and backup to S3 storage using the internal tools.
Log monitoring or external syslog — helps catch suspicious behaviour that fail2ban might miss.
User and permission audits — especially if more people will access server.
Depending on your needs, you might build a “hardening-v2” script on top of this.
This script is intended as a simple, repeatable starting point — a “secure default” server you can reliably deploy without fuss. For many personal projects, small web apps, or container-based services, this baseline is already a big upgrade over leaving a default VPS open on every port, with password SSH, and no firewall.
Considering a VPS, or looking to change provider?
I recommend Fasthosts. I’ve been using Fasthosts for a number of internet services for a long time, and I couldn’t be happier with the service, uptime, and price. Find out more here.
Security is a process, not a checkbox. Think of this as “Stage One: Locking the front door and putting up the first fence.” Over time you may layer more fences, alarms, cameras. But you’ve started strong.
If you decide to build on this (hardening-v2, backups, IPv6 lockdown, intrusion detection) — I’d be happy to help you sketch that out too. Get in touch!
I design, build, and tinker with the web — from WordPress and Shopify to self-hosted tools on my “Hey Lorna” server. This blog is where I share the experiments, lessons, and odd little projects that shape my work.