# How to bootstrap a fresh Ubuntu VPS for Ansible deployments

May 3, 2026

> **Supported targets: Ubuntu 22.04 and 24.04 only.** The platform's `roles/base` asserts 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

```sh
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

```sh
ssh root@"$SERVER_IP"
```

## Update the server

```sh
apt update
apt upgrade -y
reboot
```

## Reconnect as root

```sh
ssh root@"$SERVER_IP"
```

## Create an admin user

```sh
adduser <your-admin-user>
usermod -aG sudo <your-admin-user>
```

## Copy the root SSH key to the admin user

```sh
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

```sh
ssh <your-admin-user>@<your-server-ip>
```

## Install base packages

```sh
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

```sh
sudo systemctl enable nginx
sudo systemctl start nginx
sudo systemctl status nginx --no-pager
```

## Test Nginx locally

```sh
curl -I http://127.0.0.1
```

## Configure the firewall

```sh
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 OpenSSH` opens port 22 — use the explicit port number instead.

## Install Node.js 24

```sh
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

```sh
node --version
npm --version
```

## Install pnpm 11

```sh
sudo npm install -g pnpm@11
pnpm --version
```

## Create deployment directories

```sh
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

```sh
certbot --version
```

> Do not use `certbot --nginx` when Ansible owns the Nginx configuration.

```sh
certbot certonly --webroot --help
```

> **Ansible users:**`acleron-platform` does not store VPS connection details in `inventory.ini`. Each server is registered in `~/.acleron/vault-servers.yml` under a single `vault_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 named `vault-<alias>.yml`. The default SSH user is `deploy` (set in `ansible/group_vars/all.yml`). After bootstrap completes, root SSH is disabled, and the platform connects as the admin user automatically.

## Harden SSH

```sh
sudo nano /etc/ssh/sshd_config
```

### Use these SSH settings

```sh
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Port 14341
```

## Restart SSH

```sh
sudo systemctl restart ssh
```

## Test SSH before closing the root session

```sh
ssh -p 14341 <your-admin-user>@<your-server-ip>
```

## Enable unattended security updates

```sh
sudo dpkg-reconfigure unattended-upgrades
```

## Enable fail2ban

```sh
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
sudo systemctl status fail2ban --no-pager
```

## Verify the server baseline

```sh
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

```sh
A     <your-domain.com>       <your-server-ip>
A     www.<your-domain.com>   <your-server-ip>
```

## Verify DNS from the local machine

```sh
dig +short <your-domain.com>
dig +short www.<your-domain.com>
```

## Create a GitHub deploy key

```sh
su - <your-admin-user>

ssh-keygen -t ed25519 \
  -C "<your-admin-user>@<your-server-ip>" \
  -f ~/.ssh/github_deploy_key
```

## Print the deploy key

```sh
cat ~/.ssh/github_deploy_key.pub
```

## Configure GitHub SSH access

```sh
nano ~/.ssh/config
```

### Add the GitHub SSH config

```sh
Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/github_deploy_key
  IdentitiesOnly yes
```

## Lock SSH permissions

```sh
chmod 700 ~/.ssh
chmod 600 ~/.ssh/config
chmod 600 ~/.ssh/github_deploy_key
```

## Test GitHub SSH access

```sh
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`)

```sh
# 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:

```sh
#!/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:

```sh
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.

```yaml
# ~/.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

```sh
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

```sh
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:

```sh
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.

```sh
ansible-playbook -i ansible/inventory.ini ansible/playbooks/deploy.yml \
  -e project_config=../my-project/infra/project.yml
```

---

**References:**

- [Ansible installation guide](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html)
- [Certbot instructions](https://certbot.eff.org/instructions)
- [NodeSource Node.js packages](https://deb.nodesource.com/)
