Initial server init setup with Ansible playbook

Automated server provisioning with Pangolin reverse proxy, Forgejo git
server with SSH passthrough, and OpenCode dev environment. Includes
server hardening (UFW, fail2ban, SSH lockdown), Docker, Rust, Python/uv,
and unattended security upgrades.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nelis 2026-03-28 10:34:20 +00:00
commit c34d2b4849
10 changed files with 781 additions and 0 deletions

42
init.sh Executable file
View file

@ -0,0 +1,42 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "=== Server Init ==="
echo ""
read -p "Username [ubuntu]: " OLD_USER
OLD_USER="${OLD_USER:-ubuntu}"
read -p "Host: " HOST
read -p "SSH public key path [~/.ssh/id_ed25519.pub]: " PUBKEY_PATH
PUBKEY_PATH="${PUBKEY_PATH:-$HOME/.ssh/id_ed25519.pub}"
read -p "New username: " NEW_USER
SSH_TARGET="${OLD_USER}@pangolin.${HOST}"
echo ""
echo "Set a password for '$NEW_USER' (used for sudo):"
read -s -p "Password: " USER_PASSWORD
echo
read -s -p "Confirm: " USER_PASSWORD_CONFIRM
echo
if [ "$USER_PASSWORD" != "$USER_PASSWORD_CONFIRM" ]; then
echo "Error: Passwords do not match"
exit 1
fi
if [ ! -f "$PUBKEY_PATH" ]; then
echo "Error: Public key file not found: $PUBKEY_PATH"
exit 1
fi
PUBKEY=$(cat "$PUBKEY_PATH")
export SCRIPT_DIR HOST OLD_USER NEW_USER SSH_TARGET PUBKEY USER_PASSWORD
source "$SCRIPT_DIR/scripts/01_create_user.sh"
source "$SCRIPT_DIR/scripts/02_remove_old_user.sh"
source "$SCRIPT_DIR/scripts/03_run_playbook.sh"
source "$SCRIPT_DIR/scripts/04_show_setup_info.sh"

404
playbook.yml Normal file
View file

@ -0,0 +1,404 @@
---
- name: Server Initialization Playbook
hosts: localhost
connection: local
become: yes
vars:
base_domain:
pangolin_domain: "pangolin.{{ base_domain }}"
forgejo_domain: "forgejo.{{ base_domain }}"
opencode_domain: "opencode.{{ base_domain }}"
acme_email: "admin@{{ base_domain }}"
docker_hosting_dir: "/home/{{ login_user }}/docker_hosting"
resources_dir: "{{ playbook_dir }}/resources"
login_user: "{{ ansible_env.SUDO_USER | default(ansible_user_id) }}"
deb_architecture: "{{ 'amd64' if ansible_architecture == 'x86_64' else 'arm64' if ansible_architecture == 'aarch64' else ansible_architecture }}"
tasks:
- name: Update apt cache
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
- name: Install prerequisites for Docker repository
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
- lsb-release
state: present
- name: Install common dev dependencies
ansible.builtin.apt:
name:
- git
- vim
- wget
- htop
- tmux
- unzip
- zip
- build-essential
- gcc
- make
- pkg-config
- libssl-dev
- libffi-dev
- python3
- python3-pip
- python3-venv
- nodejs
- npm
- jq
state: present
# --- Server hardening ---
- name: Install security packages
ansible.builtin.apt:
name:
- ufw
- fail2ban
- unattended-upgrades
- apt-listchanges
state: present
- name: Disable root SSH login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
validate: 'sshd -t -f %s'
notify: restart sshd
- name: Disable SSH password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
validate: 'sshd -t -f %s'
notify: restart sshd
- name: Disable SSH challenge-response authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?KbdInteractiveAuthentication'
line: 'KbdInteractiveAuthentication no'
validate: 'sshd -t -f %s'
notify: restart sshd
- name: Set SSH MaxAuthTries
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?MaxAuthTries'
line: 'MaxAuthTries 3'
validate: 'sshd -t -f %s'
notify: restart sshd
- name: UFW - Allow forwarding for Docker
ansible.builtin.lineinfile:
path: /etc/default/ufw
regexp: '^DEFAULT_FORWARD_POLICY='
line: 'DEFAULT_FORWARD_POLICY="ACCEPT"'
- name: UFW - Set default deny incoming
community.general.ufw:
direction: incoming
default: deny
- name: UFW - Set default allow outgoing
community.general.ufw:
direction: outgoing
default: allow
- name: UFW - Allow SSH
community.general.ufw:
rule: allow
port: '22'
proto: tcp
- name: UFW - Allow HTTP
community.general.ufw:
rule: allow
port: '80'
proto: tcp
- name: UFW - Allow HTTPS
community.general.ufw:
rule: allow
port: '443'
proto: tcp
- name: UFW - Allow WireGuard
community.general.ufw:
rule: allow
port: '51820'
proto: udp
- name: UFW - Allow WireGuard clients
community.general.ufw:
rule: allow
port: '21820'
proto: udp
- name: UFW - Allow Docker to reach opencode on host
community.general.ufw:
rule: allow
from_ip: 172.18.0.0/16
to_ip: any
port: '3000'
proto: tcp
- name: UFW - Enable firewall
community.general.ufw:
state: enabled
- name: Configure fail2ban for SSH
ansible.builtin.copy:
dest: /etc/fail2ban/jail.local
mode: '0644'
content: |
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
- name: Start and enable fail2ban
ansible.builtin.service:
name: fail2ban
state: started
enabled: yes
- name: Enable unattended security upgrades
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: '0644'
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
- name: Create keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: Add Docker GPG key
ansible.builtin.shell:
cmd: curl -fsSL https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg -o /etc/apt/keyrings/docker.asc
args:
creates: /etc/apt/keyrings/docker.asc
- name: Add Docker repository
ansible.builtin.apt_repository:
repo: "deb [arch={{ deb_architecture }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable"
state: present
- name: Install Docker
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: yes
- name: Start and enable Docker
ansible.builtin.service:
name: docker
state: started
enabled: yes
- name: Add user to docker group
ansible.builtin.user:
name: "{{ login_user }}"
groups: docker
append: yes
- name: Install Rust via rustup for login user
ansible.builtin.shell:
cmd: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
args:
creates: "/home/{{ login_user }}/.cargo/bin/rustup"
become_user: "{{ login_user }}"
- name: Install uv (Python package manager)
ansible.builtin.shell:
cmd: curl -LsSf https://astral.sh/uv/install.sh | sh
args:
creates: "/home/{{ login_user }}/.local/bin/uv"
become_user: "{{ login_user }}"
# --- Forgejo SSH passthrough ---
- name: Create forgejo-shell directory
ansible.builtin.file:
path: /etc/forgejo/bin
state: directory
mode: '0755'
- name: Create forgejo-shell script
ansible.builtin.copy:
dest: /etc/forgejo/bin/forgejo-shell
mode: '0755'
content: |
#!/bin/sh
/usr/bin/docker exec -i --env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" forgejo sh "$@"
- name: Create git user for Forgejo
ansible.builtin.user:
name: git
system: yes
shell: /etc/forgejo/bin/forgejo-shell
home: /home/git
create_home: yes
register: git_user
- name: Set git user UID/GID facts
ansible.builtin.set_fact:
git_uid: "{{ git_user.uid }}"
git_gid: "{{ git_user.group }}"
- name: Add git user to docker group
ansible.builtin.user:
name: git
groups: docker
append: yes
- name: Configure SSH passthrough for git user
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/forgejo-ssh-passthrough.conf
mode: '0644'
content: |
Match User git
AuthorizedKeysCommandUser git
AuthorizedKeysCommand /usr/bin/docker exec -i forgejo /usr/local/bin/gitea keys -c /etc/gitea/app.ini -e git -u %u -t %t -k %k
notify: restart sshd
- name: Generate Pangolin secret
ansible.builtin.shell:
cmd: openssl rand -base64 48
register: pangolin_secret_result
changed_when: false
- name: Set Pangolin secret fact
ansible.builtin.set_fact:
pangolin_secret: "{{ pangolin_secret_result.stdout }}"
# --- Directory structure ---
- name: Create docker_hosting directory structure
ansible.builtin.file:
path: "{{ docker_hosting_dir }}/{{ item }}"
state: directory
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0755'
loop:
- docker_data/pangolin
- docker_data/pangolin/db
- docker_data/pangolin/letsencrypt
- docker_data/pangolin/logs
- docker_data/pangolin/traefik
- docker_data/pangolin/traefik/logs
- name: Create Forgejo data directory
ansible.builtin.file:
path: "{{ docker_hosting_dir }}/docker_data/forgejo/data"
state: directory
owner: git
group: git
mode: '0755'
# --- Pangolin config files ---
- name: Copy Pangolin config.yml
ansible.builtin.template:
src: "{{ resources_dir }}/pangolin/config.yml.j2"
dest: "{{ docker_hosting_dir }}/docker_data/pangolin/config.yml"
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0600'
force: no
- name: Copy Traefik static config
ansible.builtin.template:
src: "{{ resources_dir }}/pangolin/traefik_config.yml.j2"
dest: "{{ docker_hosting_dir }}/docker_data/pangolin/traefik/traefik_config.yml"
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0644'
- name: Copy Traefik dynamic config
ansible.builtin.template:
src: "{{ resources_dir }}/pangolin/dynamic_config.yml.j2"
dest: "{{ docker_hosting_dir }}/docker_data/pangolin/traefik/dynamic_config.yml"
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0644'
- name: Copy compose.yml
ansible.builtin.template:
src: "{{ resources_dir }}/compose.yml.j2"
dest: "{{ docker_hosting_dir }}/compose.yml"
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0644'
# --- OpenCode ---
- name: Install opencode
ansible.builtin.shell:
cmd: curl -fsSL https://opencode.ai/install | bash
args:
creates: /home/{{ login_user }}/.opencode/bin/opencode
become_user: "{{ login_user }}"
- name: Create opencode systemd service
ansible.builtin.copy:
dest: /etc/systemd/system/opencode.service
mode: '0644'
content: |
[Unit]
Description=OpenCode Server
After=network.target
[Service]
Type=simple
User={{ login_user }}
WorkingDirectory=/home/{{ login_user }}
ExecStart=/home/{{ login_user }}/.opencode/bin/opencode serve --port 3000 --hostname 0.0.0.0
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
- name: Start and enable opencode service
ansible.builtin.systemd:
name: opencode
state: started
enabled: yes
daemon_reload: yes
# --- Start services ---
- name: Start all services via docker compose
ansible.builtin.shell:
cmd: docker compose up -d
chdir: "{{ docker_hosting_dir }}"
handlers:
- name: restart sshd
ansible.builtin.service:
name: ssh
state: restarted

74
resources/compose.yml.j2 Normal file
View file

@ -0,0 +1,74 @@
name: pangolin
services:
pangolin:
image: docker.io/fosrl/pangolin:latest
container_name: pangolin
restart: unless-stopped
volumes:
- ./docker_data/pangolin:/app/config
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
interval: "10s"
timeout: "10s"
retries: 15
gerbil:
image: docker.io/fosrl/gerbil:latest
container_name: gerbil
restart: unless-stopped
depends_on:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:
- ./docker_data/pangolin:/var/config
cap_add:
- NET_ADMIN
- SYS_MODULE
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "51820:51820/udp"
- "21820:21820/udp"
- "443:443"
- "80:80"
traefik:
image: docker.io/traefik:v3.6
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil
depends_on:
pangolin:
condition: service_healthy
command:
- --configFile=/etc/traefik/traefik_config.yml
volumes:
- ./docker_data/pangolin/traefik:/etc/traefik:ro
- ./docker_data/pangolin/letsencrypt:/letsencrypt
- ./docker_data/pangolin/traefik/logs:/var/log/traefik
forgejo:
image: codeberg.org/forgejo/forgejo:7
container_name: forgejo
restart: unless-stopped
volumes:
- ./docker_data/forgejo/data:/data
environment:
- USER_UID={{ git_uid }}
- USER_GID={{ git_gid }}
- FORGEJO__service__DISABLE_REGISTRATION=true
- FORGEJO__server__ROOT_URL=https://{{ forgejo_domain }}
- FORGEJO__server__HTTP_PORT=3001
- FORGEJO__server__DISABLE_SSH=false
- FORGEJO__server__START_SSH_SERVER=false
- FORGEJO__server__SSH_PORT=22
networks:
default:
driver: bridge
name: pangolin

View file

@ -0,0 +1,48 @@
app:
dashboard_url: "https://{{ pangolin_domain }}"
log_level: "info"
save_logs: true
server:
external_port: 3000
internal_port: 3001
next_port: 3002
internal_hostname: "pangolin"
secret: "{{ pangolin_secret }}"
dashboard_session_length_hours: 720
resource_session_length_hours: 720
cors:
origins:
- "https://{{ pangolin_domain }}"
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false
domains:
domain1:
base_domain: "{{ base_domain }}"
cert_resolver: "letsencrypt"
traefik:
http_entrypoint: "web"
https_entrypoint: "websecure"
cert_resolver: "letsencrypt"
gerbil:
base_endpoint: "{{ pangolin_domain }}"
start_port: 51820
clients_start_port: 21820
rate_limits:
global:
window_minutes: 1
max_requests: 100
auth:
window_minutes: 1
max_requests: 10
flags:
require_email_verification: false
disable_signup_without_invite: true
disable_user_create_org: false
allow_raw_resources: true

View file

@ -0,0 +1,92 @@
http:
middlewares:
badger:
plugin:
badger:
disableForwardAuth: true
redirect-to-https:
redirectScheme:
scheme: https
routers:
# --- Pangolin dashboard ---
main-app-router-redirect:
rule: "Host(`{{ pangolin_domain }}`)"
service: next-service
entryPoints:
- web
middlewares:
- redirect-to-https
- badger
next-router:
rule: "Host(`{{ pangolin_domain }}`) && !PathPrefix(`/api/v1`)"
service: next-service
entryPoints:
- websecure
middlewares:
- badger
tls:
certResolver: letsencrypt
api-router:
rule: "Host(`{{ pangolin_domain }}`) && PathPrefix(`/api/v1`)"
service: api-service
entryPoints:
- websecure
middlewares:
- badger
tls:
certResolver: letsencrypt
ws-router:
rule: "Host(`{{ pangolin_domain }}`)"
service: api-service
entryPoints:
- websecure
middlewares:
- badger
tls:
certResolver: letsencrypt
# --- Forgejo (public, no auth) ---
forgejo-redirect:
rule: "Host(`{{ forgejo_domain }}`)"
service: forgejo-service
entryPoints:
- web
middlewares:
- redirect-to-https
forgejo-router:
rule: "Host(`{{ forgejo_domain }}`)"
service: forgejo-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
next-service:
loadBalancer:
servers:
- url: "http://pangolin:3002"
api-service:
loadBalancer:
servers:
- url: "http://pangolin:3000"
forgejo-service:
loadBalancer:
servers:
- url: "http://forgejo:3001"
tcp:
serversTransports:
pp-transport-v1:
proxyProtocol:
version: 1
pp-transport-v2:
proxyProtocol:
version: 2

View file

@ -0,0 +1,54 @@
api:
insecure: true
dashboard: true
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
file:
filename: "/etc/traefik/dynamic_config.yml"
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.3.1"
log:
level: "INFO"
format: "common"
maxSize: 100
maxBackups: 3
maxAge: 3
compress: true
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: "{{ acme_email }}"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http:
tls:
certResolver: "letsencrypt"
encodedCharacters:
allowEncodedSlash: true
allowEncodedQuestionMark: true
serversTransport:
insecureSkipVerify: true
ping:
entryPoint: "web"

24
scripts/01_create_user.sh Executable file
View file

@ -0,0 +1,24 @@
#!/bin/bash
set -e
echo "=== Phase 1: Creating user '$NEW_USER' on $SSH_TARGET ==="
PUBKEY_B64=$(echo "$PUBKEY" | base64 -w 0)
PASS_B64=$(echo "$USER_PASSWORD" | base64 -w 0)
ssh -o StrictHostKeyChecking=accept-new "$SSH_TARGET" bash -s -- "$NEW_USER" "$PUBKEY_B64" "$PASS_B64" <<'REMOTE'
set -e
NEW_USER="$1"
PUBKEY=$(echo "$2" | base64 -d)
USER_PASSWORD=$(echo "$3" | base64 -d)
sudo adduser --disabled-password --gecos "" "$NEW_USER"
echo "$NEW_USER:$USER_PASSWORD" | sudo chpasswd
sudo usermod -aG sudo "$NEW_USER"
sudo mkdir -p /home/$NEW_USER/.ssh
echo "$PUBKEY" | sudo tee /home/$NEW_USER/.ssh/authorized_keys > /dev/null
sudo chmod 700 /home/$NEW_USER/.ssh
sudo chmod 600 /home/$NEW_USER/.ssh/authorized_keys
sudo chown -R $NEW_USER:$NEW_USER /home/$NEW_USER/.ssh
REMOTE

7
scripts/02_remove_old_user.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/bash
set -e
echo "=== Phase 2: Removing default user '$OLD_USER' ==="
ssh -t -o StrictHostKeyChecking=accept-new "$NEW_USER@pangolin.$HOST" \
"sudo pkill -u $OLD_USER 2>/dev/null || true; sleep 1; sudo deluser --remove-home $OLD_USER 2>/dev/null || sudo userdel -r $OLD_USER; echo 'Removed user: $OLD_USER'"

9
scripts/03_run_playbook.sh Executable file
View file

@ -0,0 +1,9 @@
#!/bin/bash
set -e
echo "=== Phase 3: Copying files and running playbook ==="
rsync -az --exclude '.git' "$SCRIPT_DIR/" "$NEW_USER@pangolin.$HOST:~/server_init/"
ssh -t "$NEW_USER@pangolin.$HOST" \
"bash -c 'sudo locale-gen en_US.UTF-8 && if ! command -v ansible &>/dev/null; then echo \"Installing Ansible...\"; sudo apt-get update && sudo apt-get install -y ansible-core python3-pip; fi && echo \"Running Ansible playbook...\" && cd ~/server_init && LC_ALL=en_US.UTF-8 ansible-playbook -i localhost, -c local playbook.yml -e base_domain=\"$HOST\" --ask-become-pass'"

27
scripts/04_show_setup_info.sh Executable file
View file

@ -0,0 +1,27 @@
#!/bin/bash
set -e
echo ""
echo "=== Waiting for Pangolin setup token ==="
SETUP_TOKEN=$(ssh "$NEW_USER@pangolin.$HOST" 'timeout 120 bash -c "until docker logs pangolin 2>&1 | grep -oP \"Token: \K.*\"; do sleep 2; done"')
echo ""
echo "==========================================="
echo " Server setup complete!"
echo "==========================================="
echo ""
echo " SSH: ssh $NEW_USER@pangolin.$HOST"
echo ""
echo " Pangolin setup token: $SETUP_TOKEN"
echo ""
echo " Next steps:"
echo " 1. Go to https://pangolin.$HOST/auth/initial-setup"
echo " and enter the setup token above to create your admin account."
echo ""
echo " 2. In the Pangolin dashboard, add a local site, then create"
echo " a public resource for opencode:"
echo " - Target: host.docker.internal"
echo " - Port: 3000"
echo " - Domain: opencode.$HOST"
echo "==========================================="