From f848420d8fbb511a03488a6af9f2609c74223c3f Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 6 Jul 2026 15:34:49 -0500 Subject: [PATCH] Initial server-infra setup --- .gitignore | 6 + IMPLEMENTATION.md | 265 ++++++++++++++++++++++++++++ README.md | 30 ++++ ansible.cfg | 12 ++ inventory/group_vars/all.yml | 16 ++ inventory/group_vars/webservers.yml | 2 + inventory/host_vars/desktop.yml | 6 + inventory/hosts.yml | 10 ++ playbooks/deploy-apps.yml | 7 + playbooks/site.yml | 8 + requirements.yml | 4 + roles/app-deploy/tasks/main.yml | 9 + roles/common/tasks/main.yml | 19 ++ roles/docker/tasks/main.yml | 44 +++++ roles/ufw/tasks/main.yml | 33 ++++ scripts/deploy.sh | 69 ++++++++ scripts/provision.sh | 65 +++++++ 17 files changed, 605 insertions(+) create mode 100644 .gitignore create mode 100644 IMPLEMENTATION.md create mode 100644 README.md create mode 100644 ansible.cfg create mode 100644 inventory/group_vars/all.yml create mode 100644 inventory/group_vars/webservers.yml create mode 100644 inventory/host_vars/desktop.yml create mode 100644 inventory/hosts.yml create mode 100644 playbooks/deploy-apps.yml create mode 100644 playbooks/site.yml create mode 100644 requirements.yml create mode 100644 roles/app-deploy/tasks/main.yml create mode 100644 roles/common/tasks/main.yml create mode 100644 roles/docker/tasks/main.yml create mode 100644 roles/ufw/tasks/main.yml create mode 100755 scripts/deploy.sh create mode 100755 scripts/provision.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3793942 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.retry +.vault_pass +.ansible-vault-pass +__pycache__/ +*.pyc +.venv/ diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..db9f582 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,265 @@ +# Server Infrastructure — Implementation Guide + +Ansible-based provisioning and deployment for homelab web servers. + +## Architecture + +```mermaid +flowchart TB + subgraph provision ["Provisioning (manual / rare)"] + Desktop1["Ubuntu Desktop\n(control node)"] + Desktop1 -->|ansible-playbook site.yml| Adama + Desktop1 -->|ansible-playbook site.yml| Roslin + Desktop1 -->|ansible-playbook site.yml| DesktopTarget + end + + subgraph cicd ["CI/CD (every merge to master)"] + Gitea["Gitea push"] + Gitea --> Tests["Act: unit tests"] + Tests --> Deploy["Act: deploy job"] + Deploy --> AnsibleDeploy["ansible-playbook deploy-apps.yml"] + AnsibleDeploy --> Adama2["adama"] + AnsibleDeploy --> Roslin2["roslin"] + AnsibleDeploy --> Desktop2["desktop"] + end +``` + +| Pipeline | When | Playbook | Where it runs | +|----------|------|----------|---------------| +| **Provision** | New VM, OS change, firewall, Docker install | `site.yml` | Desktop — run manually | +| **Deploy** | Green unit tests on `master` | `deploy-apps.yml` | Gitea Act runner on desktop | + +Both pipelines share the same inventory (`inventory/hosts.yml`). + +## Servers + +| Name | IP | Role | +|------|-----|------| +| adama | 10.0.0.77 | Ubuntu Server VM (Proxmox) | +| roslin | 10.0.0.176 | Ubuntu Server VM (Proxmox) | +| desktop | *see `host_vars/desktop.yml`* | Ubuntu Desktop — control node + deployment target | + +Hostname on this machine: `ryan-development-1` + +## Repo Layout + +``` +server-infra/ +├── IMPLEMENTATION.md # This file +├── README.md # Quick start +├── ansible.cfg +├── requirements.yml # Ansible Galaxy collections +├── inventory/ +│ ├── hosts.yml +│ ├── group_vars/ +│ │ ├── all.yml +│ │ └── webservers.yml +│ └── host_vars/ +│ └── desktop.yml +├── playbooks/ +│ ├── site.yml # Phase 1: provision +│ └── deploy-apps.yml # Phase 2: CI deploy (stub) +├── roles/ +│ ├── common/ # Base packages +│ ├── ufw/ # Firewall +│ ├── docker/ # Docker CE + compose plugin +│ └── app-deploy/ # App deploy (stub for Phase 2) +└── scripts/ + ├── provision.sh # Wrapper with --limit support + └── deploy.sh # Wrapper for deploy playbook +``` + +## Prerequisites (One-Time Bootstrap) + +Ansible needs SSH + sudo on each target before playbooks work. + +1. Create `westfarn` on each VM with sudo membership. +2. Copy your SSH public key from the desktop: + ```bash + ssh-copy-id westfarn@10.0.0.77 + ssh-copy-id westfarn@10.0.0.176 + ``` +3. Confirm passwordless SSH: + ```bash + ssh westfarn@10.0.0.77 + ssh westfarn@10.0.0.176 + ``` +4. On the desktop (control node), install Ansible: + ```bash + sudo apt update && sudo apt install -y ansible + # or: pip install ansible + ``` +5. Install Galaxy collections: + ```bash + cd ~/Documents/repos/server-infra + ansible-galaxy collection install -r requirements.yml + ``` +6. Update `inventory/host_vars/desktop.yml` with this machine's LAN IP. + +## Testing on a Single Server + +Use `--limit` to target one host without touching the others. Helper scripts wrap this. + +### Ping one host + +```bash +./scripts/provision.sh adama --check # dry run +ansible adama -m ping +``` + +### Provision one host + +```bash +# Dry run (no changes) +./scripts/provision.sh adama --check + +# Apply for real +./scripts/provision.sh adama + +# Same for other hosts +./scripts/provision.sh roslin +./scripts/provision.sh desktop +``` + +### Provision all hosts + +```bash +./scripts/provision.sh +``` + +### Deploy to one host (Phase 2) + +```bash +./scripts/deploy.sh adama +./scripts/deploy.sh --check roslin +``` + +Under the hood, scripts pass `--limit ` to `ansible-playbook`. + +## Phase 1: Provision (`site.yml`) + +Applies roles in order to the `webservers` group: + +| Role | Purpose | +|------|---------| +| `common` | apt update, git, python3, pip, curl, ca-certificates | +| `ufw` | Firewall: SSH from LAN only, HTTP/HTTPS public | +| `docker` | Docker CE, compose plugin, add `westfarn` to `docker` group | + +### UFW rules + +| Port | Source | Purpose | +|------|--------|---------| +| 22 | `10.0.0.0/24` | SSH (LAN only) | +| 80 | anywhere | HTTP | +| 443 | anywhere | HTTPS | +| default | deny incoming | Block everything else | + +**Warning:** Test UFW on one host first (`./scripts/provision.sh adama`). Keep a Proxmox console session open in case SSH rules lock you out. + +After Docker install, re-SSH so the `docker` group membership takes effect. + +## Phase 2: CI Deploy (`deploy-apps.yml`) + +Not fully implemented yet. Planned flow: + +1. Gitea push triggers unit tests. +2. On success, Act runner on desktop runs `deploy-apps.yml`. +3. Ansible fans out to all `webservers` hosts. + +### Planned `company_site` workflow change + +```yaml +# company_site/.gitea/workflows/deploy.yml (future) +jobs: + deploy: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ gitea.event.workflow_run.head_sha }} + + - name: Deploy to all webservers + run: | + ~/Documents/repos/server-infra/scripts/deploy.sh \ + --extra-vars "app_ref=${{ gitea.event.workflow_run.head_sha }}" +``` + +### Planned `app-deploy` role (post-dockerize) + +Per host: + +1. Clone or pull app repo at pinned SHA. +2. `docker compose pull && docker compose up -d`. +3. Optional health check. + +Pre-dockerize interim: role can rsync/systemd like current `company_site/scripts/deploy.sh`. + +## Gitea Act Runner + +**Recommended:** Single self-hosted runner on the desktop. + +- One build artifact, one orchestration point. +- VMs only run containers; no runner needed on them for deploy fan-out. +- Runner needs: Ansible, this repo checked out, SSH key to all hosts, vault password (later). + +### Runner requirements on desktop + +| Requirement | Why | +|-------------|-----| +| Ansible | Run `deploy-apps.yml` | +| `server-infra` checkout | Playbooks + inventory | +| SSH key to all hosts | Including loopback to desktop | +| Docker | Build images before push to hosts (Phase 2) | + +## SSH Keys for CI Deploy + +| Key | Used by | Purpose | +|-----|---------|---------| +| Personal key | You | Manual provisioning | +| Deploy key (runner) | Act → Ansible → hosts | Automated deploy | + +Consider a dedicated `deploy` user with limited sudo (docker only) — future hardening step. + +## Secrets (Phase 2) + +Use Ansible Vault for production secrets. Do not commit plaintext. + +```bash +ansible-vault create inventory/group_vars/webservers/vault.yml +ansible-playbook playbooks/site.yml --ask-vault-pass +``` + +Store vault password for CI in a file readable only by the Act runner (e.g. `~/.ansible-vault-pass`, mode 600). + +## Implementation Order + +| # | Task | Status | +|---|------|--------| +| 1 | Create `server-infra` repo | Done | +| 2 | Inventory with all 3 hosts | Done — update desktop IP | +| 3 | Bootstrap SSH to adama + roslin | Manual | +| 4 | `site.yml` → common, ufw, docker | Done | +| 5 | Verify `ansible webservers -m ping` | Manual | +| 6 | Test on single server: `./scripts/provision.sh adama` | Manual | +| 7 | Provision all: `./scripts/provision.sh` | Manual | +| 8 | Deploy SSH key for Act runner | Future | +| 9 | Stub `deploy-apps.yml` + update `company_site` workflow | Future | +| 10 | Dockerize `company_site` | Future (separate ticket) | +| 11 | Gitea container registry (optional) | Future | + +## Open Decisions + +1. **Desktop LAN IP** — set in `inventory/host_vars/desktop.yml`. +2. **Same app on all three?** — prod mirror vs adama=prod / roslin=staging / desktop=dev. +3. **Deploy user** — `westfarn` vs dedicated `deploy` for CI. +4. **Gitea URL** — for clone URLs in `app-deploy` role. +5. **Reverse proxy** — Caddy/nginx on host before containers? Affects Phase 2. + +## Adding a New VM + +1. Add host to `inventory/hosts.yml` under `webservers`. +2. Bootstrap SSH: `ssh-copy-id westfarn@`. +3. Test: `./scripts/provision.sh --check`. +4. Provision: `./scripts/provision.sh `. +5. Deploys automatically include new host once in `webservers` group. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6cef82 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# server-infra + +Ansible provisioning and deployment for homelab web servers. + +## Quick Start + +```bash +# Install collections (once) +ansible-galaxy collection install -r requirements.yml + +# Test connectivity to one host +ansible adama -m ping + +# Provision one host (dry run first) +./scripts/provision.sh adama --check +./scripts/provision.sh adama + +# Provision all hosts +./scripts/provision.sh +``` + +See [IMPLEMENTATION.md](IMPLEMENTATION.md) for full architecture, CI/CD plan, and phase breakdown. + +## Servers + +| Host | IP | +|------|-----| +| adama | 10.0.0.77 | +| roslin | 10.0.0.176 | +| desktop | see `inventory/host_vars/desktop.yml` | diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..0e64992 --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,12 @@ +[defaults] +inventory = inventory/hosts.yml +roles_path = roles +host_key_checking = False +retry_files_enabled = False +stdout_callback = yaml +interpreter_python = auto_silent + +[privilege_escalation] +become = True +become_method = sudo +become_user = root diff --git a/inventory/group_vars/all.yml b/inventory/group_vars/all.yml new file mode 100644 index 0000000..fb739f2 --- /dev/null +++ b/inventory/group_vars/all.yml @@ -0,0 +1,16 @@ +--- +ansible_user: westfarn +ansible_python_interpreter: /usr/bin/python3 + +admin_user: westfarn + +# UFW +ufw_ssh_port: 22 +ufw_ssh_allowed_network: 10.0.0.0/24 +ufw_allowed_tcp_ports: + - 80 + - 443 + +# Docker +docker_users: + - "{{ admin_user }}" diff --git a/inventory/group_vars/webservers.yml b/inventory/group_vars/webservers.yml new file mode 100644 index 0000000..7a0c78c --- /dev/null +++ b/inventory/group_vars/webservers.yml @@ -0,0 +1,2 @@ +--- +# Shared webserver settings (add per-environment overrides here later) diff --git a/inventory/host_vars/desktop.yml b/inventory/host_vars/desktop.yml new file mode 100644 index 0000000..023832b --- /dev/null +++ b/inventory/host_vars/desktop.yml @@ -0,0 +1,6 @@ +--- +# Update with this machine's LAN IP: ip -4 addr show scope global +ansible_host: 10.0.0.1 # FIXME: set your desktop IP + +ansible_control_node: true +act_runner_enabled: true diff --git a/inventory/hosts.yml b/inventory/hosts.yml new file mode 100644 index 0000000..c1fbe73 --- /dev/null +++ b/inventory/hosts.yml @@ -0,0 +1,10 @@ +--- +all: + children: + webservers: + hosts: + adama: + ansible_host: 10.0.0.77 + roslin: + ansible_host: 10.0.0.176 + desktop: diff --git a/playbooks/deploy-apps.yml b/playbooks/deploy-apps.yml new file mode 100644 index 0000000..212e981 --- /dev/null +++ b/playbooks/deploy-apps.yml @@ -0,0 +1,7 @@ +--- +# Phase 2: CI-triggered app deployment (stub) +- name: Deploy application to webservers + hosts: webservers + become: true + roles: + - app-deploy diff --git a/playbooks/site.yml b/playbooks/site.yml new file mode 100644 index 0000000..19684ba --- /dev/null +++ b/playbooks/site.yml @@ -0,0 +1,8 @@ +--- +- name: Provision webservers + hosts: webservers + become: true + roles: + - common + - ufw + - docker diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..3c194cf --- /dev/null +++ b/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: community.general + version: ">=8.0.0" diff --git a/roles/app-deploy/tasks/main.yml b/roles/app-deploy/tasks/main.yml new file mode 100644 index 0000000..faf9dc4 --- /dev/null +++ b/roles/app-deploy/tasks/main.yml @@ -0,0 +1,9 @@ +--- +# Stub — implement after company_site is dockerized. +# Interim: can rsync/systemd like company_site/scripts/deploy.sh + +- name: App deploy not yet implemented + ansible.builtin.debug: + msg: >- + app-deploy role is a stub. Set app_ref={{ app_ref | default('unset') }}. + Implement git pull / docker compose after dockerize ticket. diff --git a/roles/common/tasks/main.yml b/roles/common/tasks/main.yml new file mode 100644 index 0000000..cd33e53 --- /dev/null +++ b/roles/common/tasks/main.yml @@ -0,0 +1,19 @@ +--- +- name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + +- name: Install base packages + ansible.builtin.apt: + name: + - git + - python3 + - python3-pip + - python3-venv + - curl + - ca-certificates + - gnupg + - apt-transport-https + - software-properties-common + state: present diff --git a/roles/docker/tasks/main.yml b/roles/docker/tasks/main.yml new file mode 100644 index 0000000..62997e4 --- /dev/null +++ b/roles/docker/tasks/main.yml @@ -0,0 +1,44 @@ +--- +- name: Create keyrings directory + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + +- name: Add Docker GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + +- name: Add Docker apt repository + ansible.builtin.apt_repository: + repo: "deb [arch={{ docker_apt_arch }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" + state: present + filename: docker + vars: + docker_apt_arch: "{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}" + +- name: Install Docker packages + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + update_cache: true + +- name: Ensure Docker service is enabled and running + ansible.builtin.service: + name: docker + state: started + enabled: true + +- name: Add users to docker group + ansible.builtin.user: + name: "{{ item }}" + groups: docker + append: true + loop: "{{ docker_users }}" diff --git a/roles/ufw/tasks/main.yml b/roles/ufw/tasks/main.yml new file mode 100644 index 0000000..9ecf7c0 --- /dev/null +++ b/roles/ufw/tasks/main.yml @@ -0,0 +1,33 @@ +--- +- name: Install ufw + ansible.builtin.apt: + name: ufw + state: present + +- name: Set UFW default incoming policy to deny + community.general.ufw: + direction: incoming + policy: deny + +- name: Set UFW default outgoing policy to allow + community.general.ufw: + direction: outgoing + policy: allow + +- name: Allow SSH from LAN only + community.general.ufw: + rule: allow + port: "{{ ufw_ssh_port }}" + proto: tcp + from_ip: "{{ ufw_ssh_allowed_network }}" + +- name: Allow HTTP and HTTPS + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + loop: "{{ ufw_allowed_tcp_ports }}" + +- name: Enable UFW + community.general.ufw: + state: enabled diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..3f18f46 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +LIMIT="" +EXTRA_ARGS=() +EXTRA_VARS=() + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +CMD=(ansible-playbook playbooks/deploy-apps.yml "${EXTRA_ARGS[@]}" "${EXTRA_VARS[@]}") +if [[ -n "$LIMIT" ]]; then + CMD+=(--limit "$LIMIT") + echo "==> Deploying to: $LIMIT" +else + echo "==> Deploying to all webservers" +fi + +exec "${CMD[@]}" diff --git a/scripts/provision.sh b/scripts/provision.sh new file mode 100755 index 0000000..cae0ea1 --- /dev/null +++ b/scripts/provision.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +LIMIT="" +EXTRA_ARGS=() + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +CMD=(ansible-playbook playbooks/site.yml "${EXTRA_ARGS[@]}") +if [[ -n "$LIMIT" ]]; then + CMD+=(--limit "$LIMIT") + echo "==> Targeting host: $LIMIT" +else + echo "==> Targeting all webservers" +fi + +exec "${CMD[@]}"