# CI/CD Pipeline Reference: Quark's Holo-Grid Ledger

This document defines the complete CI/CD strategy for building, testing, and deploying the Quark's Holo-Grid Ledger monorepo using **Forgejo Actions**, **Hetzner Cloud**, **Docker Compose**, and **OpenTofu**.

---

## 1. Design Principles

| Principle | Rationale |
|---|---|
| **Git-ops driven** | Every infrastructure and deployment change is triggered by a commit, never by manual SSH |
| **Immutable artifacts** | Every merge to `main` produces versioned Docker images stored in the Forgejo Container Registry |
| **Infrastructure-as-Code** | All Hetzner resources (servers, firewalls, networks, volumes) are declared in OpenTofu and applied via pipeline |
| **Separation of concerns** | Infrastructure changes (`infra/`) and application changes (`tongo-core/`, `web-terminal/`) use distinct pipeline stages |
| **Zero-downtime deploy** | Rolling container restarts via `docker compose up -d` with health checks ensure uninterrupted service |

---

## 2. Architecture Overview

The platform uses three isolated OpenTofu workspaces:
1. **`tooling`**: Houses the Forgejo Git host, registry, and runner (Server 1).
2. **`staging`**: Houses the staging application deployment (Server 2 + Volume).
3. **`production`**: Houses the production application deployment (Server 2 + Volume).

This keeps CI/CD workloads isolated from both environments, and ensures staging and production networks are fully partitioned.

```mermaid
graph TB
    subgraph Developer
        DEV[Developer Workstation]
    end

    subgraph "Hetzner Cloud"
        subgraph "tooling Workspace — Server 1 (cx22 · ~€5.49/mo)"
            FORGEJO[Forgejo Instance]
            REG[(Container Registry)]
            RUNNER[Actions Runner + DinD]
        end

        subgraph "staging / production Workspace — Server 2 (cx22 · ~€5.49/mo)"
            CADDY[Caddy Reverse Proxy]
            WEB[Web Terminal Container]
            TONGO[Tongo Core Container]
            VALKEY[(Valkey Cache)]
            VOL[(Hetzner Volume · 20 GB)]
            PRIVNET{{Private Network 10.0.0.0/16}}
        end
    end

    DEV -- git push --> FORGEJO
    FORGEJO -- triggers --> RUNNER
    RUNNER -- docker build + push --> REG
    RUNNER -- SSH deploy --> CADDY
    REG -- docker pull --> WEB
    REG -- docker pull --> TONGO
    CADDY --> WEB
    WEB --> VALKEY
    TONGO --> VALKEY
    VALKEY --> VOL
    CADDY -.- PRIVNET
    VALKEY -.- PRIVNET
```

### Cost Summary

| Resource | Spec | Workspace | Monthly Cost |
|---|---|---|---|
| Forgejo server | CX22 · 2 vCPU · 4 GB RAM | `tooling` | ~€5.49 |
| Application server | CX22 · 2 vCPU · 4 GB RAM | `staging` / `production` | ~€5.49 each |
| Persistent volume | 20 GB ext4 | `staging` / `production` | ~€0.96 each |
| **Total (Staging + Prod + Tools)** | | | **~€18.39** |

---

## 3. Directory Layout

New directories and files added to the monorepo root:

```
quarkshologridledger/
├── .forgejo/
│   └── workflows/
│       ├── ci.yml                     # Lint, test, build on every push/PR
│       ├── deploy.yml                 # Build images, push to registry, deploy on main merge
│       └── infra.yml                  # OpenTofu plan/apply for infrastructure changes
├── infra/                             # OpenTofu infrastructure-as-code
│   ├── versions.tf                    # Provider version constraints (hcloud ~> 1.49)
│   ├── variables.tf                   # Input variables (token, SSH key, domains)
│   ├── network.tf                     # Private network, subnets, firewalls, SSH keys
│   ├── forgejo.tf                     # Forgejo server resource + cloud-init
│   ├── app.tf                         # Application server + volume resources
│   ├── outputs.tf                     # Server IPs, volume ID, network ID
│   ├── cloud-init/
│   │   ├── forgejo.yml                # Bootstrap: Docker + Forgejo + runner compose
│   │   └── app.yml                    # Bootstrap: Docker + volume mount
│   ├── terraform.tfvars.example       # Example variable values (never commit real)
│   └── .gitignore                     # Ignore state files, .terraform/, tfvars
├── deploy/
│   ├── docker-compose.prod.yml        # Production compose (Caddy, Tongo, Web, Valkey)
│   ├── Caddyfile                      # Reverse proxy + automatic HTTPS
│   └── .env.example                   # Template for production env vars
├── tongo-core/
│   └── Dockerfile                     # Multi-stage Go build
├── web-terminal/
│   └── Dockerfile                     # Multi-stage Node build (Vinxi/Nitro)
└── docker-compose.yml                 # Local dev compose (existing — unchanged)
```

---

## 4. Pipeline Stages

### 4.1 CI Pipeline — Every Push & Pull Request

**File:** `.forgejo/workflows/ci.yml`
**Trigger:** All pushes and pull requests to any branch.
**Purpose:** Fast feedback loop — lint, type-check, unit test, and build verification.

```yaml
name: CI

on:
  push:
    branches: ['*']
  pull_request:
    branches: [main]

jobs:
  # ── Go Backend ──────────────────────────────────────────
  tongo-core:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.23'

      - name: Lint
        working-directory: tongo-core
        run: |
          go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
          golangci-lint run ./...

      - name: Test
        working-directory: tongo-core
        run: go test -race -coverprofile=coverage.out ./...

      - name: Build
        working-directory: tongo-core
        run: go build -o bin/tongo-core ./cmd/tongo-core

  # ── Frontend ────────────────────────────────────────────
  web-terminal:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Install
        working-directory: web-terminal
        run: npm ci

      - name: Lint & Format Check
        working-directory: web-terminal
        run: npm run format

      - name: Type Check
        working-directory: web-terminal
        run: npx tsc --noEmit

      - name: Build
        working-directory: web-terminal
        run: npm run build
```

> [!NOTE]
> Forgejo Actions uses the same YAML syntax as GitHub Actions. The runner executes
> jobs inside containers via Docker-in-Docker (DinD), so all standard actions are
> compatible.

---

### 4.2 Deploy Pipeline — Merge to Main

**File:** `.forgejo/workflows/deploy.yml`
**Trigger:** Push to `main` branch only.
**Purpose:** Build production Docker images, push to the Forgejo Container Registry, then SSH into the production server and pull + restart.

```yaml
name: Deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: forgejo.yourdomain.com
  IMAGE_TONGO: forgejo.yourdomain.com/costap/quarkshologridledger/tongo-core
  IMAGE_WEB: forgejo.yourdomain.com/costap/quarkshologridledger/web-terminal

jobs:
  build-and-push:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Set image tag
        run: echo "TAG=sha-$(git rev-parse --short HEAD)" >> $GITHUB_ENV

      - name: Login to Forgejo Registry
        run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login $REGISTRY -u ${{ secrets.REGISTRY_USER }} --password-stdin

      - name: Build & Push Tongo Core
        run: |
          docker build -t $IMAGE_TONGO:$TAG -t $IMAGE_TONGO:latest -f tongo-core/Dockerfile .
          docker push $IMAGE_TONGO:$TAG
          docker push $IMAGE_TONGO:latest

      - name: Build & Push Web Terminal
        run: |
          docker build -t $IMAGE_WEB:$TAG -t $IMAGE_WEB:latest -f web-terminal/Dockerfile .
          docker push $IMAGE_WEB:$TAG
          docker push $IMAGE_WEB:latest

  deploy:
    runs-on: self-hosted
    needs: build-and-push
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        run: |
          mkdir -p ~/.ssh
          echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key

          # Copy production compose file
          scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
            deploy/docker-compose.prod.yml \
            deploy/Caddyfile \
            deploy@$DEPLOY_HOST:/opt/hologrid/

          # Pull new images and restart
          ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no deploy@$DEPLOY_HOST << 'EOF'
            cd /opt/hologrid
            echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
            docker compose -f docker-compose.prod.yml pull
            docker compose -f docker-compose.prod.yml up -d --remove-orphans
            docker image prune -f
          EOF
```

> [!IMPORTANT]
> **Secrets required in Forgejo repository settings:**
> - `REGISTRY_TOKEN` — Personal Access Token with `packages:write` scope
> - `REGISTRY_USER` — Forgejo username
> - `DEPLOY_HOST` — Production server IP (from OpenTofu output)
> - `DEPLOY_SSH_KEY` — Private SSH key for deployment user

---

### 4.3 Infrastructure Pipeline — Changes to `infra/`

**File:** `.forgejo/workflows/infra.yml`
**Trigger:** Push to `main` with changes in `infra/**`.
**Purpose:** Plan and apply OpenTofu changes to Hetzner Cloud resources.

```yaml
name: Infrastructure

on:
  push:
    branches: [main]
    paths: ['infra/**']
  pull_request:
    branches: [main]
    paths: ['infra/**']

jobs:
  plan:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Setup OpenTofu
        uses: opentofu/setup-opentofu@v1

      - name: Init
        working-directory: infra
        run: tofu init

      - name: Plan
        working-directory: infra
        env:
          HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
        run: tofu plan -out=tfplan

      - name: Upload plan artifact
        if: github.event_name == 'pull_request'
        uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: infra/tfplan

  apply:
    runs-on: self-hosted
    needs: plan
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4

      - name: Setup OpenTofu
        uses: opentofu/setup-opentofu@v1

      - name: Init & Apply
        working-directory: infra
        env:
          HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
        run: |
          tofu init
          tofu apply -auto-approve
```

> [!WARNING]
> The `apply` job runs **only** on direct pushes to `main`, never on PRs. PRs
> will show the `plan` output for review. Consider adding a manual approval gate
> for production infrastructure changes as the project matures.

---

## 5. Infrastructure Layer (OpenTofu)

All infrastructure is declared in `infra/` and managed via the `infra.yml` pipeline.
See the scaffolded files for the full implementation.

### 5.1 Resource Map

| Resource | Type | Purpose |
|---|---|---|
| `hcloud_ssh_key.deploy` | SSH Key | Deployment access to both servers |
| `hcloud_network.internal` | Network | Private network `10.0.0.0/16` for inter-server communication |
| `hcloud_network_subnet.servers` | Subnet | `10.0.1.0/24` in `eu-central` zone |
| `hcloud_firewall.forgejo` | Firewall | Forgejo server: allow ports 22, 80, 443 inbound |
| `hcloud_firewall.app` | Firewall | App server: allow ports 22, 80, 443 inbound |
| `hcloud_server.forgejo` | Server (cx22) | Forgejo instance + Actions runner + DinD |
| `hcloud_server.app` | Server (cx22) | Production app stack (Caddy, Tongo, Web, Valkey) |
| `hcloud_volume.data` | Volume (20 GB) | Persistent Valkey data, mounted at `/mnt/data` |
| `hcloud_volume_attachment.data` | Attachment | Binds volume to app server |
| `hcloud_server_network.forgejo` | Network Attach | Forgejo server on private network |
| `hcloud_server_network.app` | Network Attach | App server on private network |

### 5.2 Server Specifications

| Parameter | Forgejo Server | Application Server |
|---|---|---|
| Role | Git forge, CI runner, container registry | Production app stack |
| Type | `cx22` (2 vCPU, 4 GB RAM) | `cx22` (2 vCPU, 4 GB RAM) |
| Image | `ubuntu-24.04` | `ubuntu-24.04` |
| Location | `nbg1` (Nuremberg) | `nbg1` (Nuremberg) |
| Volume | — | 20 GB ext4 at `/mnt/data` |
| Cloud-init | `infra/cloud-init/forgejo.yml` | `infra/cloud-init/app.yml` |

### 5.3 Cloud-Init Bootstrap

Each server is provisioned immutably via `cloud-init` templates in `infra/cloud-init/`.

**Forgejo server (`forgejo.yml`)** — on first boot:
1. Installs Docker Engine + Compose plugin
2. Creates `deploy` user with SSH key + docker group
3. Disables root SSH login
4. Writes a Docker Compose file to `/opt/forgejo/` containing:
   - Forgejo v9 (ports 3000 + 2222, Actions enabled)
   - Docker-in-Docker daemon for the runner
   - Forgejo Actions runner (requires one-time manual registration)
5. Starts Forgejo via `docker compose up -d`

**Application server (`app.yml`)** — on first boot:
1. Installs Docker Engine + Compose plugin
2. Creates `deploy` user with SSH key + docker group
3. Disables root SSH login
4. Formats and mounts the Hetzner Volume at `/mnt/data`
5. Creates `/opt/hologrid/` ready for CI-deployed compose files

> [!NOTE]
> Cloud-init templates use OpenTofu `templatefile()` interpolation.
> Variables like `${ssh_public_key}`, `${forgejo_domain}`, and `${volume_id}`
> are injected at `tofu apply` time.

---

## 6. Production Docker Compose

### 6.1 `deploy/docker-compose.prod.yml`

```yaml
services:
  caddy:
    image: caddy:2-alpine
    container_name: hologrid-caddy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      web-terminal:
        condition: service_healthy
    restart: unless-stopped

  tongo-core:
    image: forgejo.yourdomain.com/costap/quarkshologridledger/tongo-core:latest
    container_name: hologrid-tongo
    environment:
      - TONGO_CACHE_ADDR=valkey:6379
      - TONGO_LOG_LEVEL=info
    depends_on:
      valkey:
        condition: service_healthy
    restart: unless-stopped

  web-terminal:
    image: forgejo.yourdomain.com/costap/quarkshologridledger/web-terminal:latest
    container_name: hologrid-web
    environment:
      - VALKEY_URL=redis://valkey:6379
      - PORT=3000
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
      interval: 10s
      timeout: 5s
      retries: 3
    depends_on:
      valkey:
        condition: service_healthy
    restart: unless-stopped

  valkey:
    image: valkey/valkey:8.0
    container_name: hologrid-valkey
    volumes:
      - /mnt/data/valkey:/data
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

volumes:
  caddy_data:
  caddy_config:
```

### 6.2 `deploy/Caddyfile`

```caddyfile
hologrid.yourdomain.com {
    reverse_proxy web-terminal:3000
}
```

> [!TIP]
> Caddy automatically provisions and renews Let's Encrypt TLS certificates.
> No certbot configuration required. Just point your DNS A record to the
> Hetzner server IP.

---

## 7. Dockerfiles

### 7.1 `tongo-core/Dockerfile`

```dockerfile
# ── Build Stage ───────────────────────────────────────────
FROM golang:1.23-alpine AS builder

WORKDIR /build
COPY tongo-core/go.mod tongo-core/go.sum ./
RUN go mod download

COPY tongo-core/ .
COPY data-source/ /data-source/

RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /tongo-core ./cmd/tongo-core

# ── Runtime Stage ─────────────────────────────────────────
FROM alpine:3.20

RUN apk --no-cache add ca-certificates
COPY --from=builder /tongo-core /usr/local/bin/tongo-core
COPY --from=builder /data-source /data-source

ENTRYPOINT ["tongo-core"]
CMD ["serve"]
```

### 7.2 `web-terminal/Dockerfile`

```dockerfile
# ── Build Stage ───────────────────────────────────────────
FROM node:22-alpine AS builder

WORKDIR /app
COPY web-terminal/package.json web-terminal/package-lock.json ./
RUN npm ci

COPY web-terminal/ .
RUN npm run build

# ── Runtime Stage ─────────────────────────────────────────
FROM node:22-alpine

WORKDIR /app
COPY --from=builder /app/.output .output
COPY --from=builder /app/package.json .

EXPOSE 3000

CMD ["node", ".output/server/index.mjs"]
```

---

## 8. Secrets & Environment Variables

All secrets are stored in **Forgejo Repository Settings → Actions → Secrets**.

| Secret Name | Description | Used By |
|---|---|---|
| `HCLOUD_TOKEN` | Hetzner Cloud API token | `infra.yml` |
| `REGISTRY_TOKEN` | Forgejo PAT with `packages:write` | `deploy.yml` |
| `REGISTRY_USER` | Forgejo username | `deploy.yml` |
| `DEPLOY_HOST` | Production server IP address | `deploy.yml` |
| `DEPLOY_SSH_KEY` | Private SSH key for `deploy` user | `deploy.yml` |

> [!CAUTION]
> Never commit real tokens, keys, or passwords to the repository. The
> `terraform.tfvars` file is `.gitignore`-d. All sensitive values flow through
> Forgejo Secrets → pipeline environment variables → runtime containers.

---

## 9. Forgejo Runner Setup

The Forgejo Actions runner runs on the **Forgejo server** (Server 1), deployed automatically via cloud-init alongside the Forgejo instance.
The runner uses Docker-in-Docker (DinD) to execute workflow jobs in isolated containers.

### 9.1 Compose Stack (auto-deployed via cloud-init)

The `infra/cloud-init/forgejo.yml` template writes a Docker Compose file to `/opt/forgejo/docker-compose.yml`
containing three services:

| Service | Image | Purpose |
|---|---|---|
| `forgejo` | `codeberg.org/forgejo/forgejo:9` | Git forge, container registry, Actions coordinator |
| `runner-dind` | `docker:dind` | Privileged Docker daemon for runner job execution |
| `runner` | `data.forgejo.org/forgejo/runner:6.2.0` | Actions runner daemon (connects to DinD) |

### 9.2 One-Time Runner Registration

After the Forgejo server boots and the services are running, SSH in and register the runner:

```bash
# SSH into the Forgejo server
ssh deploy@<FORGEJO_IP>

# Enter the runner container
cd /opt/forgejo
docker compose exec runner forgejo-runner register \
  --instance https://forgejo.yourdomain.com \
  --token <REGISTRATION_TOKEN> \
  --name hologrid-runner \
  --labels self-hosted \
  --no-interactive

# Restart to apply config
docker compose restart runner
```

> [!TIP]
> Get the registration token from Forgejo: **Site Administration → Actions → Runners → Create New Runner**.
> This is a one-time operation. The runner persists its config in a Docker volume.

---

## 10. Deployment Flow Summary

```mermaid
sequenceDiagram
    participant Dev as Developer
    participant Forgejo as Forgejo (Server 1)
    participant Runner as CI Runner (Server 1)
    participant Registry as Container Registry (Server 1)
    participant App as App Server (Server 2)

    Dev->>Forgejo: git push (feature branch)
    Forgejo->>Runner: Trigger ci.yml
    Runner->>Runner: Lint + Test + Build (Go & Node)
    Runner-->>Forgejo: ✓ Status check passes

    Dev->>Forgejo: Merge PR → main
    Forgejo->>Runner: Trigger deploy.yml
    Runner->>Runner: docker build (tongo-core, web-terminal)
    Runner->>Registry: docker push :sha-abc1234 + :latest
    Runner->>App: SSH → copy compose + pull + up -d
    App->>Registry: docker pull latest images (via private network)
    App->>App: Rolling restart containers
    App-->>Runner: ✓ Deployment complete
```

---

## 11. Future Enhancements

| Enhancement | Priority | Description |
|---|---|---|
| **Staging environment** | Medium | Add a second Hetzner server for pre-production validation before promoting to prod |
| **Database migrations** | Medium | Add a `migrate` job in `deploy.yml` if persistent SQL storage is added later |
| **Smoke tests** | Medium | Add a post-deploy health check job that curls the production URL and validates HTTP 200 |
| **Notifications** | Low | Integrate Forgejo webhooks to send deployment status to a Matrix/Discord channel |
| **Horizontal scaling** | Low | Move from single server to Docker Swarm or Nomad when traffic demands it |
| **Secrets manager** | Low | Replace Forgejo Secrets with HashiCorp Vault or Infisical for rotation policies |
| **Monitoring stack** | Low | Add Prometheus + Grafana containers to the production compose for observability |
| **Automatic rollback** | Low | If the post-deploy health check fails, automatically roll back to the previous image tag |

---

## 12. Decision Log

| Decision | Chosen | Alternatives Considered | Rationale |
|---|---|---|---|
| **Forgejo hosting** | Self-hosted on Hetzner CX22 (~€5.49/mo) | Codeberg (free, FOSS only), Codey managed (~€39/mo), same-server co-hosting | Cheapest option with full control; separate server isolates CI builds from production; built-in container registry eliminates Docker Hub dependency |
| **Server topology** | Two CX22 servers + private network | Single server for everything, dedicated auction server | Isolation between CI and production workloads; either can be rebuilt independently; total cost ~€12/mo |
| CI/CD engine | Forgejo Actions | Woodpecker CI, Drone | Native integration with Forgejo, GitHub Actions syntax familiarity, built-in container registry |
| Container registry | Forgejo Packages | Harbor, Docker Hub | Zero additional infrastructure — ships with every Forgejo instance |
| IaC tool | OpenTofu | Pulumi, Ansible | Declarative HCL, excellent Hetzner provider, open-source fork of Terraform |
| Reverse proxy | Caddy | Nginx, Traefik | Automatic HTTPS with zero config, single binary, Caddyfile simplicity |
| Server provisioning | cloud-init | Ansible, Packer | No agent required, runs once at boot, perfect for immutable infrastructure |
| Deployment strategy | SSH + docker compose pull | Watchtower, Kubernetes | Simplest approach for a two-server deployment, easy to reason about |
| Persistent storage | Hetzner Volume | Local disk, NFS | Survives server rebuilds, attached to app server only |
