Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions terraform/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ORB self-host server — Terraform module

Provisions a single Hetzner Cloud VPS for the **ORB** self-host stack (the persistent HTTP review service), with
Docker + Docker Compose pre-installed via cloud-init and a persistent volume mounted at `/data` for the SQLite DB
and Litestream WAL segments.

It is **not** the [`packages/loopover-miner/terraform/`](../packages/loopover-miner/terraform/) module, which
provisions a fleet-mode AMS miner host and exposes no public endpoints. This module serves public HTTP(S), so its
firewall opens the Caddy ports to the internet and keeps everything else admin-scoped.

## What it creates

| Resource | Purpose |
| -------------------------- | ---------------------------------------------------------------------------------------------- |
| `hcloud_server` | One Ubuntu 24.04 VM (`server_type` default `cx22` = 2 vCPU / 4 GB, sufficient for <50 reviews/day) |
| `hcloud_firewall` | Inbound 22 (admin), 80 + 443/tcp + 443/udp (public, Caddy), 8787 (admin), 3000 (admin, opt-in) |
| `hcloud_volume` (+ attach) | Persistent ext4 volume mounted at `/data` so the DB and WAL survive re-provisioning |
| `hcloud_ssh_key` | Your SSH public key, for access |

## Prerequisites

- [Terraform](https://developer.hashicorp.com/terraform/install) `>= 1.6`
- A Hetzner Cloud project + API token (console.hetzner.cloud → Security → API Tokens)
- An SSH key pair

## Usage

```sh
cd terraform

export TF_VAR_hcloud_token="…" # or set it in a *.tfvars file (never commit it)
terraform init
terraform plan -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)"
terraform apply -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)"
```

Useful variables (see [`variables.tf`](variables.tf) for all): `server_type`, `location`, `volume_size_gb`,
`admin_ip_allowlist` (restrict this to your IP in production), `expose_grafana`.

## After apply — start the stack

The module provisions the **host**; you finish setup over SSH (secrets never live in Terraform state):

1. `terraform output ssh_command` → SSH in.
2. Clone the repo and copy [`../.env.example`](../.env.example) → `.env` — it is the exhaustive reference for
every variable the stack reads.
3. `docker compose up -d` (or `docker compose --profile postgres --profile caddy up -d`).

## Reaching Grafana (`--profile observability`)

`docker compose --profile observability up -d` publishes Grafana on the host at `3000:3000`. Because the
observability profile is itself opt-in, the firewall does **not** open port 3000 by default — a default-open port
for a service most operators never start would widen the attack surface for nothing. Pick one:

**SSH tunnel (default, nothing to change).** Keep 3000 closed and forward it over your existing SSH access:

```sh
ssh -L 3000:localhost:3000 ubuntu@$(terraform output -raw server_ipv4)
# then browse http://localhost:3000
```

**Open the port to your own IP.** Set `expose_grafana = true` and re-apply. The rule is always scoped to
`admin_ip_allowlist` — never `0.0.0.0/0` like the public Caddy ports — so restrict that allowlist to your own
IP(s) first, or you will publish Grafana to the internet:

```sh
terraform apply \
-var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \
-var "expose_grafana=true" \
-var 'admin_ip_allowlist=["203.0.113.4/32"]'
```

## Outputs

| Output | Description |
| --------------- | -------------------------------------- |
| `server_ipv4` | Public IPv4 of the server |
| `server_ipv6` | Public IPv6 of the server |
| `ssh_command` | Ready-to-run SSH command |
| `volume_device` | Block device path for the data volume |
14 changes: 14 additions & 0 deletions terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ resource "hcloud_firewall" "gittensory" {
port = "8787"
source_ips = var.admin_ip_allowlist
}

# Grafana (`docker compose --profile observability`, which publishes 3000:3000 on the host). Opt-in via
# var.expose_grafana because the observability profile is itself opt-in — a default-open port for a service
# most operators never start would widen the attack surface for nothing. Left off, Grafana stays reachable
# over an SSH tunnel (see README.md). Allowlist-scoped like 8787, never 0.0.0.0/0 like the Caddy ports.
dynamic "rule" {
for_each = var.expose_grafana ? [1] : []
content {
direction = "in"
protocol = "tcp"
port = "3000"
source_ips = var.admin_ip_allowlist
}
}
}

# ── Persistent volume for /data (SQLite DB + Litestream WAL) ──────────────────
Expand Down
6 changes: 6 additions & 0 deletions terraform/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,9 @@ variable "admin_ip_allowlist" {
type = list(string)
default = ["0.0.0.0/0", "::/0"]
}

variable "expose_grafana" {
description = "Open Grafana's port (3000) to admin_ip_allowlist for `docker compose --profile observability`. Defaults to false: observability is itself an opt-in profile, and Grafana is otherwise reachable over an SSH tunnel (see README.md). Never opened publicly — the rule is always allowlist-scoped."
type = bool
default = false
}
53 changes: 53 additions & 0 deletions test/unit/root-terraform-grafana-firewall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";

import { describe, expect, it } from "vitest";

// Static structural checks for the root ORB Terraform module's Grafana exposure (#5818). docker-compose.yml's
// `grafana` service (--profile observability) publishes 3000:3000 on the host, but the firewall had no rule for
// it — not even an admin-scoped one — so an operator following main.tf's own documented flow (provision, then
// `docker compose --profile observability up -d`) got a timed-out connection with no explanation. These lock in
// the SAFETY-CRITICAL invariants a `terraform validate` can't see: the port is opt-in, and it can never be
// opened to the public. Mirrors the pattern in test/unit/miner-terraform-module.test.ts.

const DIR = "terraform";
const mainTf = readFileSync(`${DIR}/main.tf`, "utf8");
const variablesTf = readFileSync(`${DIR}/variables.tf`, "utf8");
const readme = readFileSync(`${DIR}/README.md`, "utf8");
const dockerCompose = readFileSync("docker-compose.yml", "utf8");

/** The `dynamic "rule"` block that gates Grafana's port, body included. */
const grafanaRule = /dynamic\s+"rule"\s*\{[\s\S]*?for_each\s*=\s*var\.expose_grafana[\s\S]*?\n {2}\}/.exec(mainTf)?.[0] ?? "";

describe("root Terraform module — Grafana firewall (#5818)", () => {
it("still matches the compose service it exists for: grafana publishes 3000 under the observability profile", () => {
// If this drifts, the firewall rule below is guarding the wrong port.
expect(dockerCompose).toMatch(/grafana:[\s\S]*?profiles:\s*\["observability"\]/);
expect(dockerCompose).toMatch(/grafana:[\s\S]*?ports:[\s\S]*?"3000:3000"/);
});

it("opens Grafana's port 3000, gated by var.expose_grafana", () => {
expect(grafanaRule, "a dynamic rule gated on var.expose_grafana must exist").not.toBe("");
expect(grafanaRule).toMatch(/port\s*=\s*"3000"/);
expect(grafanaRule).toMatch(/protocol\s*=\s*"tcp"/);
expect(grafanaRule).toMatch(/direction\s*=\s*"in"/);
});

it("INVARIANT: Grafana's port is admin-allowlist-scoped — never opened to the public like the Caddy ports", () => {
expect(grafanaRule).toMatch(/source_ips\s*=\s*var\.admin_ip_allowlist/);
expect(grafanaRule).not.toMatch(/0\.0\.0\.0\/0/);
expect(grafanaRule).not.toMatch(/::\/0/);
});

it("INVARIANT: exposure is opt-in — expose_grafana is a bool defaulting to false", () => {
const variable = /variable\s+"expose_grafana"\s*\{[\s\S]*?\n\}/.exec(variablesTf)?.[0] ?? "";
expect(variable, "expose_grafana must be declared").not.toBe("");
expect(variable).toMatch(/type\s*=\s*bool/);
expect(variable).toMatch(/default\s*=\s*false/);
expect(variable).toMatch(/description\s*=/); // every var in this file documents itself
});

it("documents both access paths, including a runnable SSH-tunnel command for the closed default", () => {
expect(readme).toMatch(/ssh -L 3000:localhost:3000/);
expect(readme).toContain("expose_grafana");
});
});