ai-server-4080 runs dta_service active/active with adama and roslin. Disable beta_dta_wsgi/prod_dta_wsgi so port 8001/8011 are free for compose. Co-authored-by: Cursor <cursoragent@cursor.com>
13 KiB
Server Infrastructure — Implementation Guide
Ansible-based provisioning and deployment for homelab web servers.
Architecture
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.
- Create
westfarnon each VM with sudo membership. - Copy your SSH public key from the control node (ai-server-4080):
ssh-copy-id westfarn@10.0.0.77 ssh-copy-id westfarn@10.0.0.176 - Confirm passwordless SSH:
ssh westfarn@10.0.0.77 ssh westfarn@10.0.0.176 - First-time only — grant passwordless sudo on each new host before the first
provision.shrun. Ubuntu 26.04 shipssudo-rsby default; Ansible's--ask-become-passdoes not recognize its password prompt, so bootstrap sudo manually over SSH instead:On the host:ssh -t westfarn@10.0.0.176 # repeat for each host IPTheecho 'westfarn ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/westfarn sudo chmod 440 /etc/sudoers.d/westfarn exitcommonrole writes the same file on later runs; this one-time step is only needed before Ansible can escalate privileges the first time. - On ai-server-4080 (control node), install Ansible:
sudo apt update && sudo apt install -y ansible # or: pip install ansible - Install Galaxy collections:
cd ~/Documents/repos/server-infra ansible-galaxy collection install -r requirements.yml - Update
inventory/host_vars/ai-server-4080.ymlwith 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
./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 before the first run.
# 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
./scripts/provision.sh
Deploy to one host (Phase 2)
./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 fromhost_apps. Ports match across adama/roslin so NPM can balanceadama:PORT+roslin:PORT.
Ports
| App | beta | prod |
|---|---|---|
| company_site | 8010 | 8000 |
| dta_service | 8011 | 8001 |
| dta_webapp (nginx) | 8081 | 8080 |
Flow
- Gitea push to
master→ repo's.gitea/workflowsruns tests. - On green, deploy job on the self-hosted runner calls:
~/Documents/repos/server-infra/scripts/deploy.sh \ --app company_site --env prod --ref "${{ gitea.sha }}" deploy-apps.ymlruns againstwebservers; each host deploys only the matching app+env from itshost_apps.
app-deploy role behavior
- django: push per-app secret from control node
{{ secrets_dir }}/<app>/<app>_<env>.envto host{{ apps_env_dir }}→ git checkout at ref → copy.envinto 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 bundleddbservice;webreadsDATABASE_URL/DB_HOSTpointing at the shared external Postgres.- Each app has its own database + user on the shared Postgres.
.gitea/workflows/deploy.yml: replace the localscripts/deploy.shstep with a call toserver-infra/scripts/deploy.sh --app <name> --env <env> --ref <sha>(keep the test/docker jobs).dta_webapp:npm run build:beta/build:prodoutput 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)
- Gitea SSH key: the
gitea-keyrole (insite.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) withDATABASE_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. - Node.js/npm/npx for the
dta_webappbuild — installed by thenodejsrole insite.yml(NodeSource,node_majordefault 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 |
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.
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 |
| 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
- Deploy user —
westfarnvs dedicateddeployfor CI. - NPM load balancing — confirm jc21 NPM can express adama+roslin upstreams (Advanced tab), else active/active is just two independent instances.
- Secrets — Ansible Vault vs per-host env files (currently per-host
/opt/apps/env/*.env).
Adding a New VM
- Add host to
inventory/hosts.ymlunderwebservers. - Bootstrap SSH:
ssh-copy-id westfarn@<new-ip>. - Test:
./scripts/provision.sh <hostname> --check. - Provision:
./scripts/provision.sh <hostname>. - Deploys automatically include new host once in
webserversgroup.