How to bootstrap a fresh Ubuntu VPS for Ansible deployments
Supported targets: Ubuntu 22.04 and 24.04 only. The platform’s
roles/baseasserts the OS at bootstrap start; non-Ubuntu hosts fail fast with a clear migration message rather than producing mysterious apt errors halfway through. Adding Debian/RHEL/Alpine support would require apt/dnf/apk branches across every install task — explicitly out of scope.
Set local values
export SERVER_IP="<your-server-ip>"
export ADMIN_USER="deploy" # default used by bootstrap.sh; override if needed
export SSH_PORT="14341" # non-standard port; bootstrap.sh hardens sshd to use this
Log in as root
ssh root@"$SERVER_IP"
Update the server
apt update
apt upgrade -y
reboot
Reconnect as root
ssh root@"$SERVER_IP"
Create an admin user
adduser <your-admin-user>
usermod -aG sudo <your-admin-user>
Copy the root SSH key to the admin user
mkdir -p /home/<your-admin-user>/.ssh
cp /root/.ssh/authorized_keys /home/<your-admin-user>/.ssh/authorized_keys
chown -R <your-admin-user>:<your-admin-user> /home/<your-admin-user>/.ssh
chmod 700 /home/<your-admin-user>/.ssh
chmod 600 /home/<your-admin-user>/.ssh/authorized_keys
Test admin SSH access
ssh <your-admin-user>@<your-server-ip>
Install base packages
sudo apt update
sudo apt install -y \
ca-certificates \
curl \
git \
nginx \
certbot \
python3 \
python3-apt \
sudo \
rsync \
acl \
build-essential \
ufw \
fail2ban \
logrotate \
unattended-upgrades \
apache2-utils
Enable Nginx
sudo systemctl enable nginx
sudo systemctl start nginx
sudo systemctl status nginx --no-pager
Test Nginx locally
curl -I http://127.0.0.1
Configure the firewall
sudo ufw allow "${SSH_PORT}/tcp"
sudo ufw allow 'Nginx Full'
sudo ufw --force enable
sudo ufw status verbose
The platform uses SSH port 14341 (not 22).
ufw allow OpenSSHopens port 22 — use the explicit port number instead.
Install Node.js 24
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" \
| sudo tee /etc/apt/sources.list.d/nodesource.list > /dev/null
sudo apt update
sudo apt install -y nodejs
Verify Node.js
node --version
npm --version
Install pnpm 11
sudo npm install -g pnpm@11
pnpm --version
Create deployment directories
sudo mkdir -p /srv
sudo mkdir -p /var/www/_letsencrypt/.well-known/acme-challenge
sudo mkdir -p /var/log/apps
sudo chown -R www-data:www-data /var/www/_letsencrypt
sudo chmod -R 755 /var/www/_letsencrypt
Verify Certbot
certbot --version
Do not use
certbot --nginxwhen Ansible owns the Nginx configuration.
certbot certonly --webroot --help
Ansible users:
acleron-platformdoes not store VPS connection details ininventory.ini. Each server is registered in~/.acleron/vault-servers.ymlunder a singlevault_servers:dict, with optional per-server overrides on each entry (port,user,key,bastion,ssh_args,python_interpreter). Per-VPS project secrets live in sibling files namedvault-<alias>.yml. The default SSH user isdeploy(set inansible/group_vars/all.yml). After bootstrap completes, root SSH is disabled, and the platform connects as the admin user automatically.
Harden SSH
sudo nano /etc/ssh/sshd_config
Use these SSH settings
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Port 14341
Restart SSH
sudo systemctl restart ssh
Test SSH before closing the root session
ssh -p 14341 <your-admin-user>@<your-server-ip>
Enable unattended security updates
sudo dpkg-reconfigure unattended-upgrades
Enable fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
sudo systemctl status fail2ban --no-pager
Verify the server baseline
python3 --version
sudo -V | head -n 1
nginx -v
certbot --version
git --version
rsync --version | head -n 1
node --version
pnpm --version
systemctl --version | head -n 1
sudo ufw status
Create DNS records
A <your-domain.com> <your-server-ip>
A www.<your-domain.com> <your-server-ip>
Verify DNS from the local machine
dig +short <your-domain.com>
dig +short www.<your-domain.com>
Create a GitHub deploy key
su - <your-admin-user>
ssh-keygen -t ed25519 \
-C "<your-admin-user>@<your-server-ip>" \
-f ~/.ssh/github_deploy_key
Print the deploy key
cat ~/.ssh/github_deploy_key.pub
Configure GitHub SSH access
nano ~/.ssh/config
Add the GitHub SSH config
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/github_deploy_key
IdentitiesOnly yes
Lock SSH permissions
chmod 700 ~/.ssh
chmod 600 ~/.ssh/config
chmod 600 ~/.ssh/github_deploy_key
Test GitHub SSH access
ssh -T git@github.com
Run the full bootstrap script instead
The platform ships bootstrap.sh in the acleron-platform repo. Copy it to the server and run it as root. It accepts two optional env vars:
ADMIN_USER— admin username to create (default:deploy)SSH_PORT— SSH port to harden sshd to (default:14341)
# On your local machine: copy the script to the server
scp acleron-platform/acleron-platform/bootstrap.sh root@"$SERVER_IP":/root/bootstrap.sh
# On the server as root:
chmod +x bootstrap.sh
./bootstrap.sh
# Or with custom values:
ADMIN_USER=myuser SSH_PORT=14341 ./bootstrap.sh
What the script does:
#!/usr/bin/env bash
set -euo pipefail
ADMIN_USER="${ADMIN_USER:-deploy}"
SSH_PORT="${SSH_PORT:-14341}"
# Creates admin user, grants passwordless sudo, copies root's authorized_keys
# Installs: nginx, certbot, git, rsync, ufw, fail2ban, build-essential, apache2-utils
# Creates /srv, /var/www/_letsencrypt, /var/log/apps
# Installs Node.js 24 from NodeSource
# Installs pnpm 11 globally
# Opens UFW: SSH port + Nginx Full; enables UFW
# Configures unattended security upgrades, enables fail2ban
# Hardens SSH: PermitRootLogin no, PasswordAuthentication no, Port $SSH_PORT; restarts sshd
IMPORTANT: Before closing your root session, verify the admin user can SSH in on the new port:
ssh -p 14341 deploy@<your-server-ip>
Register the server in the vault
The platform reads server addresses from ~/.acleron/vault-servers.yml, not from inventory.ini. Add an alias for the new server before running any Ansible commands.
# ~/.acleron/vault-servers.yml (decrypt first if encrypted)
vault_servers:
server_1:
host: <your-server-ip>
# Optional: bastion (user@host string only; the playbook builds the ProxyCommand)
# bastion: jump@your.bastion.host
# Optional: override default SSH port/user/key
# port: 22
# user: ubuntu
# key: ~/.ssh/id_ed25519
Defaults for the SSH port, user, key, and extra args live in ansible/group_vars/all.yml. Override per server only when one VPS differs from your defaults.
Test Ansible reachability
mise run servers # list all registered aliases
mise run ping -- server_1 # ping just one
mise run ping # ping all of them
Run the Ansible bootstrap playbook
mise run bootstrap -- server_1
# Skip Node.js + pnpm installation (packages and hardening only):
mise run bootstrap-no-node -- server_1
# Or directly:
ansible-playbook -i ansible/inventory.ini ansible/playbooks/bootstrap.yml -e vps=server_1
Bootstrap is per-server. Run it once per new VPS alias. Subsequent runs are idempotent but unnecessary.
If the project that’s about to live on this VPS already has an infra/project.yml with a vps: field, the bootstrap can read the alias from that file instead. From the project directory:
mise run bootstrap # if the project's mise.toml defines [tasks.bootstrap]
# Or directly:
ansible-playbook -i ../acleron-platform/acleron-platform/ansible/inventory.ini \
../acleron-platform/acleron-platform/ansible/playbooks/bootstrap.yml \
-e project_config=infra/project.yml
If both -e vps= and -e project_config= are passed, vps wins.
Deploy a project with its own infra config
The deploy playbook reads vps: from the project’s infra/project.yml, so the same command deploys to whichever VPS the project is configured for.
ansible-playbook -i ansible/inventory.ini ansible/playbooks/deploy.yml \
-e project_config=../my-project/infra/project.yml
References: