A new VM came up with the wrong firewall defaults, and a “quick” manual fix turned into 40 minutes of checking package state and user permissions. The next time, I wrote down the steps as a script—and made it safe to run again. In this guide, we’ll build a small, repeatable Bash bootstrap for Debian and Ubuntu servers, then cover how to run and verify it without locking yourself out.
Decide what the script should own
A provisioning script is most useful when it handles a short list of predictable tasks: install baseline packages, create an administrative user, install that user’s public SSH key, and set basic firewall rules.
Keep application deployment, secrets, and provider-specific networking out of a first-pass script. Those concerns vary by project and environment. This example also does not change SSH daemon settings or disable password authentication; make those changes only after confirming key-based access works.
The script below expects a fresh Debian or Ubuntu server with systemd and apt. Run it from the provider’s console or over an existing SSH session as root. Don’t test a new access setup by closing your only working session.
Write the provisioning script
Save this as provision.sh:
#!/usr/bin/env bash
set -Eeuo pipefail
log() {
printf '[%s] %s\n' "$(date -Is)" "$*"
}
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
[[ $EUID -eq 0 ]] || fail "Run this script as root."
[[ -n "${ADMIN_USER:-}" ]] || fail "Set ADMIN_USER."
[[ -n "${PUBLIC_KEY_FILE:-}" ]] || fail "Set PUBLIC_KEY_FILE."
[[ "$ADMIN_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || fail "Invalid ADMIN_USER."
[[ -r "$PUBLIC_KEY_FILE" ]] || fail "Cannot read PUBLIC_KEY_FILE."
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
source /etc/os-release
else
fail "Cannot identify operating system."
fi
case "${ID:-}" in
ubuntu|debian) ;;
*) fail "This script supports Debian and Ubuntu only." ;;
esac
log "Updating package metadata and installing baseline tools."
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
fail2ban \
git \
ufw
if ! id "$ADMIN_USER" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "$ADMIN_USER"
fi
if getent group sudo >/dev/null; then
usermod -aG sudo "$ADMIN_USER"
elif getent group wheel >/dev/null; then
usermod -aG wheel "$ADMIN_USER"
else
fail "Could not find a sudo or wheel group."
fi
home_dir="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
[[ -n "$home_dir" && -d "$home_dir" ]] || fail "Could not find the user's home directory."
install -d -m 700 -o "$ADMIN_USER" -g "$ADMIN_USER" "$home_dir/.ssh"
install -m 600 -o "$ADMIN_USER" -g "$ADMIN_USER" \
"$PUBLIC_KEY_FILE" "$home_dir/.ssh/authorized_keys"
systemctl enable --now fail2ban
log "Configuring UFW. Allowing SSH before enabling the firewall."
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw --force enable
log "Provisioning complete for $ADMIN_USER."
Make it executable:
chmod 750 provision.sh
The script uses set -Eeuo pipefail so command failures and unset variables don’t pass silently. It also checks its operating system and inputs before making changes. The package install and user creation can be repeated; UFW rules may report that they already exist, but the final policy remains the same.
Run it with a public key
On the server, place the administrator’s public key in a readable file, such as /root/admin.pub. Do not copy a private key to the server. Then run:
sudo env \
ADMIN_USER=deploy \
PUBLIC_KEY_FILE=/root/admin.pub \
./provision.sh
If you’re already root, you can omit sudo env and set the variables directly:
ADMIN_USER=deploy PUBLIC_KEY_FILE=/root/admin.pub ./provision.sh
Check that the user exists, the services are active, and UFW allows SSH:
id deploy
systemctl is-active fail2ban
ufw status verbose
Open a second terminal and test the new account before ending your original session:
ssh -i ~/.ssh/id_ed25519 deploy@your-server
Replace the key path and server name with your own values. The account will need a public key whose matching private key is available on your client.
Be careful with firewall and SSH changes
ufw allow OpenSSH uses the firewall application profile named OpenSSH. Check that the profile exists with ufw app list; if your SSH daemon listens on a nonstandard port, allow that port before enabling UFW. Cloud firewalls and network ACLs are separate from UFW, so check both layers if SSH is unreachable.
A practical tip from experience: don’t combine “create a new admin user,” “change the SSH port,” and “disable password login” into one untested run. That makes it harder to identify the cause of a lockout. First confirm key-based login in a second session; then change SSH settings in a separate, reversible step.
Another useful habit is to keep the script in version control and test it on a throwaway VM before using it on a long-lived server. Shell scripts are executable infrastructure: a typo in a firewall rule can have a real outage attached to it.
Make it repeatable, not magical
Bash is a good fit for a small bootstrap, but it isn’t a full configuration-management system. This example doesn’t manage drift—for example, it won’t remove packages you no longer want or detect every manual change. Keep the script focused, review changes before re-running it on production, and log each run somewhere appropriate for your environment.
For more complex setups, consider adding a dry-run mode, writing a run log to a protected file, and testing with ShellCheck:
shellcheck provision.sh
If you need frequent changes across many servers, tools such as Ansible can provide clearer state management. A Bash script can still handle the initial bootstrap that installs the prerequisites.
For compute, I’ve used/tested PowerVPS for general-purpose VPS workloads and Vast.ai when I need to rent GPU capacity. Availability, pricing, and setup options depend on the workload and region, so compare them against your requirements. The Server Rental Guide is also a useful starting point when comparing rental options.
Summary
A useful provisioning script is small, explicit, and safe to repeat. This one installs a few baseline packages, creates a sudo-capable user with a public SSH key, enables Fail2ban, and configures UFW without rewriting SSH settings.
Test it on a disposable VM, verify access in a second session, and keep firewall and SSH changes deliberate. That modest discipline turns a pile of one-off commands into a dependable starting point for the next server.
Top comments (0)