| bootstrap.bash | feat: ensure ssh vs sshd detected properly for systemctl | |
| README.md | generate bootstrapping script for vps + readme | |
VPS Bootstrap Script
One-shot provisioning script for a fresh Ubuntu 24.04 VPS with Docker pre-installed. Hardens the OS, locks down SSH to key-only auth, sets up a firewall, and deploys a modular Docker Compose stack for CI and authentication services.
What it does
| Stage | Description |
|---|---|
| System update | apt upgrade, installs ufw, fail2ban, unattended-upgrades, curl, git, jq |
| Auto-updates | Configures unattended-upgrades for security patches only |
| User creation | Creates a sudo + docker user, fetches SSH keys from Codeberg, optionally appends a manual key |
| SSH hardening | Drops /etc/ssh/sshd_config.d/hardened.conf: root login off, passwords off, pubkey only, MaxAuthTries 3. Validates with sshd -t before restart (rolls back on failure) |
| UFW firewall | Default deny inbound, allow outbound. Opens ports based on selected services |
| fail2ban | SSH jail: 5 retries, 1h ban, 10m find window, ufw ban action |
| Secret generation | openssl rand for all service secrets at bootstrap time |
| Docker Compose | Generates docker-compose.yml, Caddyfile, .env, .env.example under ~/services/ |
Services
The stack is modular. Caddy and the Forgejo Runner are always included. The authentication services can be toggled independently.
| Service | Image | Role | Ports | Flag |
|---|---|---|---|---|
| Caddy | caddy:2 |
Reverse proxy, automatic TLS via Let's Encrypt | 80, 443 | always |
| Forgejo Runner | data.forgejo.org/forgejo/runner:11 |
CI runner for Codeberg / Forgejo Actions | none (outbound only) | always |
| Docker-in-Docker | docker:dind |
Isolated Docker daemon for CI jobs | internal TCP 2375 | always |
| lldap | lldap/lldap:stable |
Lightweight LDAP server | 389, 636 (host) / 17170 (Caddy) | --skip-lldap to exclude |
| Pocket ID | ghcr.io/pocket-id/pocket-id:v2 |
OIDC provider (passkey auth) | 1411 (Caddy) | --skip-pocket-id to exclude |
Network isolation
| Network | Services |
|---|---|
frontend |
Caddy, lldap, Pocket ID |
backend |
lldap (internal LDAP queries between containers) |
ci |
Docker-in-Docker, Forgejo Runner |
Prerequisites
- Fresh Ubuntu 24.04 server
- Docker and Docker Compose already installed
- Root or sudo access
- A domain with DNS you control (for automatic TLS)
- SSH public key(s) on your Codeberg profile
Usage
sudo ./bootstrap.sh \
--username <vps-user> \
--codeberg-user <codeberg-username> \
--domain <base-domain> \
[--ssh-pubkey "<public-key-string>"] \
[--skip-lldap] \
[--skip-pocket-id]
Arguments
| Argument | Required | Description |
|---|---|---|
--username |
yes | Username for the new sudo account |
--codeberg-user |
yes | Codeberg username (SSH keys fetched from codeberg.org/<user>.keys) |
--domain |
yes | Base domain for services (e.g. example.com) |
--ssh-pubkey |
no | Additional SSH public key to authorize |
--skip-lldap |
no | Exclude lldap from the stack |
--skip-pocket-id |
no | Exclude Pocket ID from the stack |
Examples
Full stack w/ all services:
sudo ./bootstrap.sh \
--username deploy \
--codeberg-user myuser \
--domain example.com
Runner only (no auth services):
sudo ./bootstrap.sh \
--username deploy \
--codeberg-user myuser \
--domain example.com \
--skip-lldap \
--skip-pocket-id
Pocket ID but no lldap:
sudo ./bootstrap.sh \
--username deploy \
--codeberg-user myuser \
--domain example.com \
--skip-lldap
W/ an additional SSH key:
sudo ./bootstrap.sh \
--username deploy \
--codeberg-user myuser \
--domain example.com \
--ssh-pubkey "ssh-ed25519 AAAA... user@host"
Firewall rules
| Rule | Port | Condition |
|---|---|---|
| SSH | 22/tcp | always |
| HTTP | 80/tcp | always |
| HTTPS | 443/tcp | always |
| LDAP | 389/tcp | only with lldap |
| LDAPS | 636/tcp | only with lldap |
| Default inbound | * | deny |
| Default outbound | * | allow |
Generated files
After running, ~/services/ contains:
~/services/
├── docker-compose.yml # Service stack (conditional on flags)
├── Caddyfile # Reverse proxy routes (conditional on flags)
├── .env # Generated secrets — DO NOT COMMIT
├── .env.example # Safe-to-commit template with placeholder values
├── caddy/
│ ├── data/ # TLS certificates (managed by Caddy)
│ └── config/
├── runner/
│ └── data/ # Runner state, config, cache
├── lldap/ # (only if lldap enabled)
│ └── data/
└── pocket-id/ # (only if Pocket ID enabled)
└── data/
Post-Bootstrap
-
Review secrets in
~/services/.env -
Point DNS A records to your server IP:
auth.<domain>-- Pocket ID (if enabled)ldap.<domain>-- lldap web UI (if enabled)
-
Start the stack:
cd ~/services && docker compose up -d -
Register the Forgejo Runner (one-time setup):
# Switch to the sleep command in docker-compose.yml, then: docker compose up -d forgejo-runner docker exec -it forgejo-runner /bin/sh # Inside the container: forgejo-runner generate-config > config.yml forgejo-runner register \ --instance https://codeberg.org \ --token <YOUR_TOKEN> \ --name <RUNNER_NAME> \ --labels "docker:docker://node:20-bookworm,ubuntu-latest:docker://ubuntu:24.04" exit # Switch back to the daemon command, then: docker compose up -d forgejo-runner -
Change the lldap admin password (if enabled) — default user is
admin, log in athttps://ldap.<domain>. -
Set up Pocket ID (if enabled) — the first user to register at
https://auth.<domain>becomes the admin. -
Verify SSH access before closing your current session:
ssh <username>@<server-ip>Password auth is now disabled. If you cannot connect with your key, your current session is still active and you can fix it.
SSH hardening details
The script writes /etc/ssh/sshd_config.d/hardened.conf with:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
X11Forwarding no
MaxAuthTries 3
MaxSessions 5
ClientAliveInterval 300
ClientAliveCountMax 2
AllowAgentForwarding no
AllowTcpForwarding no
The config is validated with sshd -t before sshd is restarted. If validation
fails, the hardened config is removed and the script exits.
Design notes
- Docker-in-Docker for the Forgejo Runner avoids exposing the host Docker socket to CI workloads. The runner talks to an isolated DinD sidecar over internal TCP.
- Caddy handles TLS automatically via Let's Encrypt with zero manual cert management.
- lldap maps its internal ports (3890/6360) to standard LDAP ports (389/636) on the host so other servers can authenticate directly.
- The script is idempotent where possible — it checks for an existing user before creating one, and validates sshd config before restarting.
- Secrets are never logged — generated via
openssl randand written directly to.env.