Sync runner checkout / sync (pull_request) Successful in 5s
Keeps ~/Documents/repos/server-infra on the Act runner up to date after direct pushes and merged PRs so CI deploy steps use current playbooks.
358 lines
13 KiB
Markdown
358 lines
13 KiB
Markdown
# Server Infrastructure — Implementation Guide
|
|
|
|
Ansible-based provisioning and deployment for homelab web servers.
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph provision ["Provisioning (manual / rare)"]
|
|
Control1["ai-server-4080\n(control node)"]
|
|
Control1 -->|ansible-playbook site.yml| Adama
|
|
Control1 -->|ansible-playbook site.yml| Roslin
|
|
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"]
|
|
end
|
|
```
|
|
|
|
| Pipeline | When | Playbook | Where it runs |
|
|
|----------|------|----------|---------------|
|
|
| **Provision** | New VM, OS change, firewall, Docker install | `site.yml` | ai-server-4080 — run manually |
|
|
| **Deploy** | Green unit tests on `master` | `deploy-apps.yml` | Gitea Act runner on ai-server-4080 |
|
|
|
|
Both pipelines share the same inventory (`inventory/hosts.yml`).
|
|
|
|
## Servers
|
|
|
|
| Name | IP | Role |
|
|
|------|-----|------|
|
|
| adama | 10.0.0.77 | Ubuntu Server VM (Proxmox) — app host |
|
|
| roslin | 10.0.0.176 | Ubuntu Server VM (Proxmox) — app host |
|
|
| ai-server-4080 | 10.0.0.128 | Control node + Gitea act runner (no app workloads) |
|
|
|
|
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 # vars + app_catalog
|
|
│ └── host_vars/
|
|
│ ├── adama.yml # host_apps (django + dta_webapp)
|
|
│ ├── roslin.yml # host_apps (mirrors adama)
|
|
│ └── ai-server-4080.yml # control node / act runner, no workloads
|
|
├── playbooks/
|
|
│ ├── site.yml # Phase 1: provision
|
|
│ └── deploy-apps.yml # Phase 2: CI deploy
|
|
├── roles/
|
|
│ ├── common/ # Base packages
|
|
│ ├── ufw/ # Firewall
|
|
│ ├── docker/ # Docker CE + compose plugin
|
|
│ ├── nodejs/ # Node.js + npm + npx (NodeSource)
|
|
│ ├── gitea-key/ # per-server SSH key + Gitea access probe
|
|
│ ├── tianji/ # Monitoring reporter
|
|
│ ├── app-deploy/ # django (docker) + node-static deploy
|
|
│ └── web-static/ # nginx container serving /var/www builds
|
|
└── 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 control node (ai-server-4080):
|
|
```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. **First-time only** — grant passwordless sudo on each new host before the first
|
|
`provision.sh` run. Ubuntu 26.04 ships `sudo-rs` by default; Ansible's
|
|
`--ask-become-pass` does not recognize its password prompt, so bootstrap sudo
|
|
manually over SSH instead:
|
|
```bash
|
|
ssh -t westfarn@10.0.0.176 # repeat for each host IP
|
|
```
|
|
On the host:
|
|
```bash
|
|
echo 'westfarn ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/westfarn
|
|
sudo chmod 440 /etc/sudoers.d/westfarn
|
|
exit
|
|
```
|
|
The `common` role writes the same file on later runs; this one-time step is only
|
|
needed before Ansible can escalate privileges the first time.
|
|
5. On ai-server-4080 (control node), install Ansible:
|
|
```bash
|
|
sudo apt update && sudo apt install -y ansible
|
|
# or: pip install ansible
|
|
```
|
|
6. Install Galaxy collections:
|
|
```bash
|
|
cd ~/Documents/repos/server-infra
|
|
ansible-galaxy collection install -r requirements.yml
|
|
```
|
|
7. Update `inventory/host_vars/ai-server-4080.yml` with this machine's LAN IP (`ansible_host`).
|
|
|
|
## 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
|
|
|
|
New hosts need the one-time passwordless sudo bootstrap in
|
|
[Prerequisites](#prerequisites-one-time-bootstrap) before the first run.
|
|
|
|
```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 ai-server-4080
|
|
```
|
|
|
|
### 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 <hostname>` 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 |
|
|
| `nodejs` | Node.js + npm + npx (NodeSource) for `dta_webapp` builds |
|
|
| `gitea-key` | Per-server SSH key + Gitea access probe |
|
|
| `tianji` | Monitoring reporter |
|
|
|
|
### 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`)
|
|
|
|
### Apps
|
|
|
|
| App | Type | Hosts | Notes |
|
|
|-----|------|-------|-------|
|
|
| `company_site` | django (docker) | adama + roslin | active/active behind NPM |
|
|
| `dta_service` | django (docker) | adama + roslin + ai-server-4080 | active/active behind NPM |
|
|
| `dta_webapp` | node/vite static | adama + roslin | active/active; built to `/var/www/<env>_dta_webapp`, served by web-static nginx |
|
|
|
|
Both environments (`beta`, `prod`) are deployed. Django apps use a **shared external
|
|
Postgres** (via `DATABASE_URL` in each host's env file) so active/active replicas
|
|
share one database.
|
|
|
|
### Data model
|
|
|
|
- `app_catalog` (`group_vars/all.yml`) — how each app is built (repo, type, compose file, migrate cmd).
|
|
- `host_apps` (`host_vars/<host>.yml`) — which app+env+port runs on that host.
|
|
- Django app = one compose project per env: project name `<app>_<env>`, host port from `host_apps`.
|
|
Ports match across adama/roslin so NPM can balance `adama:PORT` + `roslin:PORT`.
|
|
|
|
### Ports
|
|
|
|
| App | beta | prod |
|
|
|-----|------|------|
|
|
| company_site | 8010 | 8000 |
|
|
| dta_service | 8011 | 8001 |
|
|
| dta_webapp (nginx) | 8081 | 8080 |
|
|
|
|
### Flow
|
|
|
|
1. Gitea push to `master` → repo's `.gitea/workflows` runs tests.
|
|
2. On green, deploy job on the self-hosted runner calls:
|
|
```bash
|
|
~/Documents/repos/server-infra/scripts/deploy.sh \
|
|
--app company_site --env prod --ref "${{ gitea.sha }}"
|
|
```
|
|
3. `deploy-apps.yml` runs against `webservers`; each host deploys only the
|
|
matching app+env from its `host_apps`.
|
|
|
|
### `app-deploy` role behavior
|
|
|
|
- **django**: push per-app secret from control node `{{ secrets_dir }}/<app>/<app>_<env>.env`
|
|
to host `{{ apps_env_dir }}` → git checkout at ref → copy `.env` into checkout →
|
|
`docker compose build` → `up -d` → migrate (run once, shared DB).
|
|
- **node-static**: git checkout at ref → `npm ci` → `npm run build:<env>`
|
|
(writes to `/var/www/<env>_dta_webapp`).
|
|
- **web-static** role: one nginx container per app host (adama + roslin) serving
|
|
the static roots on their ports; NPM balances across both.
|
|
|
|
### Reverse proxy / load balancing (NPM at 10.0.0.230)
|
|
|
|
Ansible does **not** manage NPM. It only guarantees stable host ports. In NPM you
|
|
point each domain at the backend(s):
|
|
|
|
- Single host: standard Proxy Host → `adama:PORT`.
|
|
- Active/active: jc21 NPM's UI Proxy Host is single-target. To balance
|
|
adama+roslin you need the **Advanced** tab with a custom `upstream {}` block
|
|
(or a real LB). Confirm this before relying on active/active.
|
|
|
|
### Required changes IN each app repo (owned separately)
|
|
|
|
- [ ] `docker-compose.prod.yml`: drop the bundled `db` service; `web` reads
|
|
`DATABASE_URL` / `DB_HOST` pointing at the shared external Postgres.
|
|
- [ ] Each app has its own database + user on the shared Postgres.
|
|
- [ ] `.gitea/workflows/deploy.yml`: replace the local `scripts/deploy.sh` step
|
|
with a call to `server-infra/scripts/deploy.sh --app <name> --env <env> --ref <sha>`
|
|
(keep the test/docker jobs).
|
|
- [ ] `dta_webapp`: `npm run build:beta` / `build:prod` output to
|
|
`/var/www/beta_dta_webapp` / `/var/www/prod_dta_webapp`.
|
|
|
|
### Shared Postgres (10.0.0.230, same box as NPM)
|
|
|
|
One shared instance; each app+env gets its own database (beta and prod MUST NOT
|
|
share a DB — active/active replicas of the same env share one DB, different envs
|
|
do not).
|
|
|
|
| app | env | database | DATABASE_URL |
|
|
|-----|-----|----------|--------------|
|
|
| company_site | prod | `company_site` | `postgres://westfarn:<pw>@10.0.0.230:5432/company_site` |
|
|
| company_site | beta | `company_site_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/company_site_beta` |
|
|
| dta_service | prod | `dta_service` | `postgres://westfarn:<pw>@10.0.0.230:5432/dta_service` |
|
|
| dta_service | beta | `dta_service_beta` | `postgres://westfarn:<pw>@10.0.0.230:5432/dta_service_beta` |
|
|
|
|
Server prereqs on 10.0.0.230: create the 4 DBs + grant `westfarn`;
|
|
`listen_addresses` covers LAN; `pg_hba.conf` allows `10.0.0.0/24`; firewall opens
|
|
5432 to `10.0.0.0/24` only.
|
|
|
|
### One-time host bootstrap (per target)
|
|
|
|
- [x] Gitea SSH key: the `gitea-key` role (in `site.yml`) generates a key per
|
|
server, configures SSH for port 30009, probes access, and — if the server
|
|
can't reach Gitea yet — prints the public key to add and stops. Add the key
|
|
(Gitea user SSH keys, or repo Deploy Keys) and re-run provisioning.
|
|
- [ ] Create control-node secrets `{{ secrets_dir }}/<app>/<app>_<env>.env`
|
|
(default `~/Documents/secrets/<app>/<app>_<env>.env`) with `DATABASE_URL`
|
|
(see table), `DJANGO_ENV`, `DJANGO_SECRET_KEY`, `WEB_PORT` (matching the port
|
|
table). Deploy pushes these to `/opt/apps/env/<app>_<env>.env` (mode 600) on
|
|
adama + roslin. Never committed to git.
|
|
- [x] Node.js/npm/npx for the `dta_webapp` build — installed by the `nodejs`
|
|
role in `site.yml` (NodeSource, `node_major` default 20).
|
|
|
|
## Gitea Act Runner
|
|
|
|
**Recommended:** Single self-hosted runner on ai-server-4080.
|
|
|
|
- One orchestration point.
|
|
- App hosts (adama/roslin) run the workloads; 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 ai-server-4080
|
|
|
|
| Requirement | Why |
|
|
|-------------|-----|
|
|
| Ansible | Run `deploy-apps.yml` |
|
|
| `server-infra` checkout | Playbooks + inventory |
|
|
| SSH key to adama + roslin | Deploy fan-out |
|
|
|
|
On every push or merged PR to `master`, `.gitea/workflows/sync-checkout.yml`
|
|
fast-forward pulls this repo at `~/Documents/repos/server-infra` on the Act
|
|
runner so playbooks and inventory stay current without a manual `git pull`.
|
|
|
|
## 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 |
|
|
| 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 |
|
|
| 8a | Auto-sync runner checkout on `master` (`.gitea/workflows/sync-checkout.yml`) | Done |
|
|
| 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. **Deploy user** — `westfarn` vs dedicated `deploy` for CI.
|
|
2. **NPM load balancing** — confirm jc21 NPM can express adama+roslin upstreams (Advanced tab), else active/active is just two independent instances.
|
|
3. **Secrets** — Ansible Vault vs per-host env files (currently per-host `/opt/apps/env/*.env`).
|
|
|
|
## Adding a New VM
|
|
|
|
1. Add host to `inventory/hosts.yml` under `webservers`.
|
|
2. Bootstrap SSH: `ssh-copy-id westfarn@<new-ip>`.
|
|
3. Test: `./scripts/provision.sh <hostname> --check`.
|
|
4. Provision: `./scripts/provision.sh <hostname>`.
|
|
5. Deploys automatically include new host once in `webservers` group.
|