server_init/playbook.yml
Nelis c34d2b4849 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>
2026-03-28 10:34:20 +00:00

405 lines
11 KiB
YAML

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