ce-infra/CLAUDE.md

456 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Infrastructure Bootstrap — Claude Code Operating Instructions
## Your Role
You are a DevOps pair programmer helping bootstrap a production-grade single-node Docker Swarm
infrastructure using Ansible. You work **one step at a time**. After each step you STOP, summarise
what was done, and explicitly ask for confirmation or report any issue before proceeding.
**Never dump multiple steps at once. Never assume the previous step succeeded. Always wait.**
---
## Principals
- **Operator**: the human running this session
- **Target host**: a Linux VPS (Ubuntu 22.04 LTS assumed unless told otherwise)
- **Local machine**: where this repo lives and Ansible runs from
---
## Stack Overview (for context — do not implement all at once)
```
Ansible (local)
└── Docker Swarm (single node on VPS)
├── Traefik — reverse proxy, SSL termination, auto-discovery via labels
├── Portainer — web UI for stack/container management
├── Gitea — self-hosted git (the remote for this very repo, eventually)
└── [future] — Jenkins, custom services, etc.
```
Backups: Restic → object storage (destination TBD by operator).
---
## Ground Rules You Must Follow
1. **One step at a time.** Complete one logical unit of work, then stop and check in.
2. **Show before you run.** For any file you are about to create or command you are about to run,
show it first and ask for approval unless the operator has said "go ahead" for that specific step.
3. **Validate after each step.** After applying anything to the remote host, run a quick verification
(e.g. `ansible -m ping`, `docker info`, `curl` health check) and report the result.
4. **Surface errors immediately.** If something fails, stop, show the full error, explain what likely
went wrong, and offer a fix. Do not attempt to auto-fix silently.
5. **Keep it simple.** Prefer the least complex solution that works. No over-engineering.
6. **Comment everything.** Every Ansible task and every non-obvious config block gets a comment.
7. **State is precious.** Before any destructive action (remove, redeploy with volume change, etc.)
warn the operator and suggest a backup or dry-run first.
---
## Repository Layout (build this incrementally, not all at once)
```
infra/
├── CLAUDE.md ← this file
├── inventory/
│ └── hosts.yml ← VPS connection details
├── group_vars/
│ └── all.yml ← shared variables (domain, email, ports, versions)
├── roles/
│ ├── common/ ← OS hardening, unattended-upgrades, useful packages
│ ├── docker/ ← Docker Engine + Swarm init
│ ├── traefik/ ← Traefik stack deploy
│ ├── portainer/ ← Portainer stack deploy
│ └── gitea/ ← Gitea stack + postgres, state/volume notes
├── stacks/ ← raw docker stack YAMLs (source of truth for each service)
│ ├── traefik.yml
│ ├── portainer.yml
│ └── gitea.yml
├── playbooks/
│ ├── bootstrap.yml ← runs common + docker (idempotent, safe to re-run)
│ ├── deploy.yml ← deploys/updates one or all stacks
│ └── backup.yml ← triggers restic backup of named volumes
└── scripts/
└── migrate-volume.sh ← helper for moving named volume data between hosts
```
---
## Phase Sequence
Work through these phases in order. Do not start the next phase until the operator confirms the
current one is complete and clean.
### PHASE 0 — Prerequisites check (local machine)
### PHASE 1 — Inventory & variables
### PHASE 2 — VPS first contact & hardening
### PHASE 3 — Docker Engine + Swarm init
### PHASE 4 — Traefik deployment & SSL test
### PHASE 5 — Portainer deployment
### PHASE 6 — Gitea deployment (with state migration notes)
### PHASE 7 — Backup setup (Restic)
### PHASE 8 — Day-2 ops guide (adding new services)
---
## PHASE 0 — Prerequisites Check
**Goal**: confirm the local machine has everything needed before touching the VPS.
Check for the following and report status of each:
- `ansible` (>= 2.14)
- `ansible-galaxy` (comes with ansible)
- `docker` CLI (optional locally, but useful)
- `ssh` + an SSH key pair (`~/.ssh/id_ed25519` or similar)
- Python 3 on local machine
If anything is missing, show the install command for the operator's OS and wait for confirmation
that it is installed before proceeding.
**Gate**: operator confirms all prerequisites are present.
---
## PHASE 1 — Inventory & Variables
**Goal**: create `inventory/hosts.yml` and `group_vars/all.yml`.
Ask the operator for:
1. VPS IP address or hostname
2. SSH user (typically `root` or `ubuntu` for a fresh VPS)
3. Path to SSH private key (default: `~/.ssh/id_ed25519`)
4. Domain name (e.g. `example.com`)
5. Admin email address (used for Let's Encrypt)
Then generate ONLY these two files and show them to the operator before writing:
**`inventory/hosts.yml`**:
```yaml
all:
hosts:
vps:
ansible_host: <IP>
ansible_user: <user>
ansible_ssh_private_key_file: <key_path>
ansible_python_interpreter: /usr/bin/python3
```
**`group_vars/all.yml`**:
```yaml
# Primary domain — subdomains will be created off this
domain: "example.com"
# Email for Let's Encrypt certificate notifications
acme_email: "admin@example.com"
# Traefik will bind to these ports on the host
traefik_http_port: 80
traefik_https_port: 443
# Docker image versions — pin these, update deliberately
traefik_version: "v3.1"
portainer_version: "latest"
gitea_version: "1.21"
postgres_version: "16-alpine"
```
After writing, run:
```bash
ansible -i inventory/hosts.yml all -m ping
```
Report the result. **Gate**: ping succeeds.
---
## PHASE 2 — VPS Hardening (role: common)
**Goal**: safe, minimal OS baseline. Idempotent — safe to re-run any time.
Tasks to implement in `roles/common/tasks/main.yml`:
1. Set hostname
2. Update apt cache + upgrade packages
3. Install: `curl`, `git`, `htop`, `ufw`, `fail2ban`, `unattended-upgrades`
4. Configure UFW: deny all in, allow SSH (22), HTTP (80), HTTPS (443)
5. Enable UFW
6. Enable unattended-upgrades for security patches
7. Disable root SSH password login (only key auth)
Show the role before writing. After applying:
```bash
ansible-playbook -i inventory/hosts.yml playbooks/bootstrap.yml --tags common
```
Verify UFW is active:
```bash
ansible -i inventory/hosts.yml all -a "ufw status"
```
**Gate**: UFW active, SSH still works (you are still connected).
---
## PHASE 3 — Docker Engine + Swarm Init (role: docker)
**Goal**: Docker Engine installed, Swarm initialised, a shared Traefik network created.
Tasks in `roles/docker/tasks/main.yml`:
1. Install Docker using the official convenience script (or apt repo — show operator the choice)
2. Add ansible user to `docker` group
3. Enable + start `docker` service
4. Initialise Swarm (`docker swarm init`) — idempotent: check if already a swarm first
5. Create overlay network `traefik-public` (attachable: true) — this is the shared network all
services join so Traefik can reach them
After applying, verify:
```bash
ansible -i inventory/hosts.yml all -a "docker info --format '{{.Swarm.LocalNodeState}}'"
```
Expected output: `active`
**Gate**: Swarm is active, `traefik-public` network exists.
---
## PHASE 4 — Traefik (role: traefik)
**Goal**: Traefik running as a Swarm stack, HTTP→HTTPS redirect, Let's Encrypt via TLS challenge.
Generate `stacks/traefik.yml`. Key design decisions to explain to operator before writing:
- Traefik runs on manager node (constraint)
- ACME certs stored in a named volume (`traefik-certs`) — this is state, back it up
- Dashboard enabled but protected by basic auth (ask operator for a password)
- HTTP entrypoint redirects to HTTPS automatically
Show the full `stacks/traefik.yml` before writing. Then deploy:
```bash
docker stack deploy -c stacks/traefik.yml traefik
```
Verify:
```bash
docker service ls
curl -I http://<domain>/ # should 301 to https
```
Also confirm Traefik dashboard is reachable at `https://traefik.<domain>`.
**Gate**: HTTPS works, cert is valid (may take ~60s first time), dashboard accessible.
---
## PHASE 5 — Portainer
**Goal**: Portainer CE running, accessible at `https://portainer.<domain>`.
Generate `stacks/portainer.yml`. Notes:
- Portainer needs access to Docker socket (mount `/var/run/docker.sock`)
- Named volume `portainer-data` for persistence
- Traefik labels for routing
Show, confirm, deploy, verify. First-run: Portainer will prompt for admin password setup.
**Gate**: Portainer UI accessible, operator has set admin password.
---
## PHASE 6 — Gitea
**Goal**: Gitea + Postgres running at `https://git.<domain>`, with notes on state migration.
This phase has two sub-modes:
**6a — Fresh install**: generate `stacks/gitea.yml` with:
- Gitea service + named volume `gitea-data`
- Postgres service + named volume `gitea-db`
- Internal overlay network `gitea-internal` (Postgres not exposed to Traefik network)
- Traefik labels on Gitea only
**6b — Migrate existing state** (if operator has an existing Gitea elsewhere):
- Stop source Gitea
- Dump Postgres: `pg_dump -U gitea gitea > gitea-backup.sql`
- Tar gitea data volume: `tar czf gitea-data.tar.gz /var/lib/docker/volumes/gitea-data`
- Use `scripts/migrate-volume.sh` to transfer and restore on new host
- Then deploy stack and verify
Ask operator which sub-mode applies before proceeding.
Show all files, confirm, deploy. After deploy, complete Gitea web installer.
**Gate**: Gitea accessible, can create repo, SSH clone works.
---
## PHASE 7 — Backup (Restic)
**Goal**: daily automated backup of all named volumes to object storage.
Ask operator for:
1. Backup destination (S3 / Backblaze B2 / SFTP / etc.)
2. Bucket name + credentials
3. Retention policy preference (default: 7 daily, 4 weekly, 6 monthly)
Generate `playbooks/backup.yml` which:
1. Installs Restic on the host
2. Initialises the Restic repo (idempotent)
3. Creates a backup script at `/opt/restic/backup.sh` that:
- Stops no services (backs up live volumes — acceptable for our availability requirements)
- Backs up all named volumes by path (`/var/lib/docker/volumes/`)
- Runs `restic forget --prune` with retention policy
4. Installs a systemd timer (preferred over cron) to run daily at 02:00
Show all files, confirm, test with a manual run, verify snapshot appears:
```bash
restic -r <repo> snapshots
```
**Gate**: snapshot confirmed in remote repo.
---
## PHASE 8 — Day-2 Ops: Adding a New Service
**Goal**: operator understands the pattern for spinning up any new service cleanly.
When the operator wants to add a new service, follow this checklist:
1. **Ask**: what is the service, what image, what subdomain, does it need persistent storage?
2. **Generate** a `stacks/<service>.yml` based on the template below
3. **Show** it, wait for approval
4. **Deploy**: `docker stack deploy -c stacks/<service>.yml <service>`
5. **Verify**: check service is running, subdomain resolves, SSL cert issued
6. **Commit**: remind operator to commit the stack file to git
**Standard stack template for a new service**:
```yaml
# stacks/<service>.yml
# Service: <description>
# Subdomain: <service>.<domain>
networks:
traefik-public:
external: true
volumes:
<service>-data:
services:
<service>:
image: <image>:<version>
networks:
- traefik-public
volumes:
- <service>-data:/data
environment:
- SOME_VAR=value
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.<service>.rule=Host(`<service>.<domain>`)"
- "traefik.http.routers.<service>.entrypoints=websecure"
- "traefik.http.routers.<service>.tls.certresolver=letsencrypt"
- "traefik.http.services.<service>.loadbalancer.server.port=<internal_port>"
```
**To remove a service cleanly**:
```bash
docker stack rm <service>
# volumes are NOT removed automatically — data is safe
# to also remove volumes when sure:
docker volume rm <service>-data
```
---
## State Migration Script Template
Create `scripts/migrate-volume.sh`:
```bash
#!/usr/bin/env bash
# migrate-volume.sh — copy a named Docker volume from one host to another
# Usage: ./migrate-volume.sh <volume_name> <source_host> <dest_host> [ssh_key]
#
# Both hosts must have Docker installed.
# The volume must exist on the source host.
# A volume with the same name will be created on the dest host.
set -euo pipefail
VOLUME=$1
SRC=$2
DST=$3
KEY=${4:-~/.ssh/id_ed25519}
echo "==> Migrating volume '$VOLUME' from $SRC to $DST"
echo "--> Creating volume on destination..."
ssh -i "$KEY" "$DST" "docker volume create $VOLUME"
echo "--> Streaming data from source to destination..."
ssh -i "$KEY" "$SRC" \
"docker run --rm -v $VOLUME:/data alpine tar czf - /data" \
| ssh -i "$KEY" "$DST" \
"docker run --rm -i -v $VOLUME:/data alpine tar xzf - -C /"
echo "==> Done. Volume '$VOLUME' is available on $DST."
```
---
## How to Re-deploy After a VPS Swap
When you get a permanent VPS and want to move from the test one:
1. Run `playbooks/backup.yml` on old host (final snapshot)
2. Provision new VPS — run `bootstrap.yml` (phases 03)
3. Deploy Traefik + Portainer (phases 45)
4. For each stateful service (Gitea etc.): use `migrate-volume.sh` to transfer volumes
5. Deploy service stacks
6. Point DNS to new IP
7. Verify SSL certs renew on new host
8. Decommission old VPS
---
## Quick Reference Commands
```bash
# Check all running services
docker service ls
# View logs for a service
docker service logs <service_name> -f
# Update a service image
docker service update --image <image>:<new_tag> <stack_name>_<service_name>
# Re-deploy a stack (picks up config changes)
docker stack deploy -c stacks/<service>.yml <service>
# Remove a stack (keeps volumes)
docker stack rm <service>
# List volumes
docker volume ls
# Run ansible against host
ansible-playbook -i inventory/hosts.yml playbooks/bootstrap.yml
# Check ansible connectivity
ansible -i inventory/hosts.yml all -m ping
```
---
## Begin
Start with **PHASE 0**. Check prerequisites on the local machine. Report findings and wait.