--- - name: Server Initialization Playbook hosts: localhost connection: local become: yes vars: base_domain: pangolin_domain: "pangolin.{{ base_domain }}" forgejo_domain: "forgejo.{{ base_domain }}" muxplex_domain: "muxplex.{{ base_domain }}" nextcloud_domain: "nextcloud.{{ 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 }}" ssh_pubkey: "" juicefs_s3_endpoint: "" juicefs_s3_bucket: "" juicefs_s3_access_key: "" juicefs_s3_secret_key: "" juicefs_cache_size: "50G" 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 - jq - ttyd 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 muxplex on host community.general.ufw: rule: allow from_ip: 172.18.0.0/16 to_ip: any port: '8088' 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) for login user 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 -u git --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 -u git forgejo /usr/local/bin/gitea keys -c /data/gitea/conf/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 }}" - name: Generate Nextcloud admin password ansible.builtin.shell: cmd: openssl rand -base64 24 register: nextcloud_admin_pass_result changed_when: false - name: Generate Nextcloud DB password ansible.builtin.shell: cmd: openssl rand -base64 24 register: nextcloud_db_pass_result changed_when: false - name: Set Nextcloud password facts ansible.builtin.set_fact: nextcloud_admin_pass: "{{ nextcloud_admin_pass_result.stdout }}" nextcloud_db_pass: "{{ nextcloud_db_pass_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 - docker_data/nextcloud - docker_data/nextcloud/db - docker_data/nextcloud/redis - 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' # --- aicoder user setup --- - name: Create aicoder user ansible.builtin.user: name: aicoder shell: /bin/bash home: /home/aicoder create_home: yes - name: Create aicoder directories ansible.builtin.file: path: "/home/aicoder/{{ item }}" state: directory owner: aicoder group: aicoder mode: '0755' loop: - codeprojects - scripts - .ssh - name: Add SSH key for aicoder ansible.builtin.copy: dest: /home/aicoder/.ssh/authorized_keys content: "{{ ssh_pubkey }}\n" owner: aicoder group: aicoder mode: '0600' when: ssh_pubkey | length > 0 - name: Set aicoder .ssh directory permissions ansible.builtin.file: path: /home/aicoder/.ssh mode: '0700' owner: aicoder group: aicoder - name: Install nvm for aicoder ansible.builtin.shell: cmd: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash args: creates: /home/aicoder/.nvm/nvm.sh become_user: aicoder - name: Install latest LTS Node.js via nvm ansible.builtin.shell: cmd: bash -c 'source ~/.nvm/nvm.sh && nvm install --lts' args: creates: /home/aicoder/.nvm/alias/lts become_user: aicoder - name: Install Claude Code via nvm npm ansible.builtin.shell: cmd: bash -c 'source ~/.nvm/nvm.sh && npm install -g @anthropic-ai/claude-code' become_user: aicoder args: creates: /home/aicoder/.nvm/versions/node - name: Install pi-coding-agent via nvm npm ansible.builtin.shell: cmd: bash -c 'source ~/.nvm/nvm.sh && npm install -g @mariozechner/pi-coding-agent' become_user: aicoder - name: Install uv for aicoder ansible.builtin.shell: cmd: curl -LsSf https://astral.sh/uv/install.sh | sh args: creates: /home/aicoder/.local/bin/uv become_user: aicoder - name: Install muxplex for aicoder ansible.builtin.shell: cmd: /home/aicoder/.local/bin/uv tool install git+https://github.com/bkrabach/muxplex become_user: aicoder args: creates: /home/aicoder/.local/bin/muxplex - name: Configure muxplex to bind to all interfaces ansible.builtin.shell: cmd: /home/aicoder/.local/bin/muxplex config set host 0.0.0.0 become_user: aicoder - name: Configure muxplex auth to none (Pangolin handles auth) ansible.builtin.shell: cmd: /home/aicoder/.local/bin/muxplex config set auth none become_user: aicoder - name: Install muxplex as systemd service ansible.builtin.shell: cmd: /home/aicoder/.local/bin/muxplex service install become_user: aicoder - name: Enable and start muxplex service ansible.builtin.systemd: name: muxplex state: started enabled: yes scope: user become_user: aicoder - name: Enable lingering for aicoder (keep user services running) ansible.builtin.shell: cmd: loginctl enable-linger aicoder args: creates: /var/lib/systemd/linger/aicoder # --- Cron cleanup script --- - name: Create build artifact cleanup script ansible.builtin.copy: dest: /home/aicoder/scripts/cleanup-build-artifacts.sh owner: aicoder group: aicoder mode: '0755' content: | #!/bin/bash PROJECTS_DIR="/home/aicoder/codeprojects" MAX_AGE_DAYS=4 # Rust - remove target directories find "$PROJECTS_DIR" -maxdepth 3 -type d -name "target" -mtime +$MAX_AGE_DAYS -exec rm -rf {} + # Node - remove node_modules directories find "$PROJECTS_DIR" -maxdepth 3 -type d -name "node_modules" -mtime +$MAX_AGE_DAYS -exec rm -rf {} + - name: Set up cron job for build artifact cleanup ansible.builtin.cron: name: "cleanup build artifacts" user: aicoder minute: "0" hour: "3" job: "/home/aicoder/scripts/cleanup-build-artifacts.sh" # --- JuiceFS + Nextcloud directories --- - name: Create JuiceFS and Nextcloud data directories ansible.builtin.file: path: "{{ docker_hosting_dir }}/{{ item }}" state: directory owner: "{{ login_user }}" group: "{{ login_user }}" mode: '0755' loop: - docker_data/nextcloud/data - docker_data/juicefs/cache # --- Start infrastructure services (Redis needs to be up before JuiceFS format) --- - name: Start Redis via docker compose ansible.builtin.shell: cmd: docker compose up -d redis chdir: "{{ docker_hosting_dir }}" - name: Wait for Redis to be ready ansible.builtin.shell: cmd: docker exec redis redis-cli ping register: redis_ping until: redis_ping.stdout == "PONG" retries: 10 delay: 3 - name: Format JuiceFS filesystem ansible.builtin.shell: cmd: > docker run --rm --network pangolin juicedata/mount:ce-v1 juicefs format --storage s3 --bucket {{ juicefs_s3_endpoint }}/{{ juicefs_s3_bucket }} --access-key {{ juicefs_s3_access_key }} --secret-key {{ juicefs_s3_secret_key }} "redis://redis:6379/1" nextcloud-data args: creates: /etc/juicefs-formatted - name: Mark JuiceFS as formatted ansible.builtin.file: path: /etc/juicefs-formatted state: touch mode: '0644' # --- Start all 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