Feat/open tofu #8
@@ -61,6 +61,7 @@ module "dev" {
|
|||||||
network_ip_range = var.network_ip_range
|
network_ip_range = var.network_ip_range
|
||||||
bootstrap_script = local.bootstrap_script
|
bootstrap_script = local.bootstrap_script
|
||||||
ssh_private_key_path = var.ssh_private_key_path
|
ssh_private_key_path = var.ssh_private_key_path
|
||||||
|
deploy_user = var.deploy_user
|
||||||
runner_count = var.dev_runner_count
|
runner_count = var.dev_runner_count
|
||||||
|
|
||||||
# module.network.id alone doesn't guarantee the subnet exists yet, and a server
|
# module.network.id alone doesn't guarantee the subnet exists yet, and a server
|
||||||
@@ -82,6 +83,7 @@ module "prod" {
|
|||||||
network_ip_range = var.network_ip_range
|
network_ip_range = var.network_ip_range
|
||||||
bootstrap_script = local.bootstrap_script
|
bootstrap_script = local.bootstrap_script
|
||||||
ssh_private_key_path = var.ssh_private_key_path
|
ssh_private_key_path = var.ssh_private_key_path
|
||||||
|
deploy_user = var.deploy_user
|
||||||
|
|
||||||
depends_on = [module.network]
|
depends_on = [module.network]
|
||||||
}
|
}
|
||||||
@@ -96,6 +98,7 @@ module "vpn" {
|
|||||||
allowed_ssh_source_ips = var.allowed_ssh_source_ips
|
allowed_ssh_source_ips = var.allowed_ssh_source_ips
|
||||||
bootstrap_script = local.bootstrap_script
|
bootstrap_script = local.bootstrap_script
|
||||||
ssh_private_key_path = var.ssh_private_key_path
|
ssh_private_key_path = var.ssh_private_key_path
|
||||||
|
deploy_user = var.deploy_user
|
||||||
}
|
}
|
||||||
|
|
||||||
module "dns" {
|
module "dns" {
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ resource "hcloud_volume" "storage" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
# Hetzner's automount convention (hc-utils' 99-hc-volume-automount.rules) is
|
||||||
|
# always /mnt/HC_Volume_<id> - deterministic once the volume exists, since the
|
||||||
|
# id is stable for the volume's lifetime regardless of which server it's
|
||||||
|
# attached to.
|
||||||
|
data_dir = "/mnt/HC_Volume_${hcloud_volume.storage.id}"
|
||||||
|
}
|
||||||
|
|
||||||
# Renders services/dev/Runners/docker-compose.yml directly into the repo, with
|
# Renders services/dev/Runners/docker-compose.yml directly into the repo, with
|
||||||
# one runner service per var.runner_count. This only touches the local working
|
# one runner service per var.runner_count. This only touches the local working
|
||||||
# tree - deploying the change still goes through the normal scp + spinup.sh flow.
|
# tree - deploying the change still goes through the normal scp + spinup.sh flow.
|
||||||
@@ -101,6 +109,74 @@ resource "local_file" "runners_compose" {
|
|||||||
filename = "${path.root}/../services/dev/Runners/docker-compose.yml"
|
filename = "${path.root}/../services/dev/Runners/docker-compose.yml"
|
||||||
content = templatefile("${path.module}/templates/runners-docker-compose.yml.tftpl", {
|
content = templatefile("${path.module}/templates/runners-docker-compose.yml.tftpl", {
|
||||||
runner_count = var.runner_count
|
runner_count = var.runner_count
|
||||||
|
data_dir = local.data_dir
|
||||||
})
|
})
|
||||||
file_permission = "0644"
|
file_permission = "0644"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# DATA_DIR is picked up automatically by `docker compose` (which auto-loads
|
||||||
|
# .env from its working directory) for every dev/*-docker-compose.yml bind
|
||||||
|
# mount - see services/dev/*.yml. Not committed (see .gitignore): it's tied to
|
||||||
|
# the live volume's ID, so it's regenerated by tofu apply, not handed off via git.
|
||||||
|
resource "local_file" "env" {
|
||||||
|
filename = "${path.root}/../services/dev/.env"
|
||||||
|
content = "DATA_DIR=${local.data_dir}\n"
|
||||||
|
file_permission = "0644"
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
# Everything under services/dev except the two files above: those are
|
||||||
|
# tracked via their own resource content (reading them back with fileset()/
|
||||||
|
# filesha1() before they exist would fail during `tofu plan`).
|
||||||
|
dev_static_files = sort([
|
||||||
|
for f in fileset("${path.root}/../services/dev", "**") :
|
||||||
|
f if f != ".env" && f != "Runners/docker-compose.yml"
|
||||||
|
])
|
||||||
|
|
||||||
|
dev_static_files_hash = sha1(join("", [
|
||||||
|
for f in local.dev_static_files : filesha1("${path.root}/../services/dev/${f}")
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copies services/dev to the server on every apply that changes it (a hand-edited
|
||||||
|
# compose file, a new runner count, ...) or recreates the server - not just once
|
||||||
|
# at first creation. Split from hcloud_server.this because it must run after the
|
||||||
|
# generated files above, which in turn depend on the volume, which depends on
|
||||||
|
# the server - so it can't be a provisioner on the server resource itself.
|
||||||
|
resource "null_resource" "deploy_services" {
|
||||||
|
triggers = {
|
||||||
|
server_id = hcloud_server.this.id
|
||||||
|
static_files = local.dev_static_files_hash
|
||||||
|
env = local_file.env.content
|
||||||
|
runners = local_file.runners_compose.content
|
||||||
|
}
|
||||||
|
|
||||||
|
connection {
|
||||||
|
type = "ssh"
|
||||||
|
host = hcloud_server.this.ipv4_address
|
||||||
|
user = "root"
|
||||||
|
private_key = file(var.ssh_private_key_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "remote-exec" {
|
||||||
|
inline = ["mkdir -p /home/${var.deploy_user}/services"]
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "file" {
|
||||||
|
source = "${path.root}/../services/dev"
|
||||||
|
destination = "/home/${var.deploy_user}/services"
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "remote-exec" {
|
||||||
|
inline = [
|
||||||
|
"chown -R ${var.deploy_user}:${var.deploy_user} /home/${var.deploy_user}/services",
|
||||||
|
"chmod +x /home/${var.deploy_user}/services/dev/*.sh",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
hcloud_server.this,
|
||||||
|
local_file.env,
|
||||||
|
local_file.runners_compose,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ output "ipv4" {
|
|||||||
output "private_ipv4" {
|
output "private_ipv4" {
|
||||||
value = var.private_ip
|
value = var.private_ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output "data_dir" {
|
||||||
|
description = "Deterministic host path of the auto-mounted volume - see local.data_dir."
|
||||||
|
value = local.data_dir
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
# GITEA_RUNNER_REGISTRATION_TOKEN is intentionally left as a compose variable
|
# GITEA_RUNNER_REGISTRATION_TOKEN is intentionally left as a compose variable
|
||||||
# (not baked in here) - services/dev/spinup.sh generates a fresh token and
|
# (not baked in here) - services/dev/spinup.sh generates a fresh token and
|
||||||
# writes it to Runners/.env immediately before starting these containers.
|
# writes it to Runners/.env immediately before starting these containers.
|
||||||
|
#
|
||||||
|
# /data lives on dev's volume (${data_dir}) so runner identity survives a
|
||||||
|
# server rebuild - baked in directly rather than via .env, since Runners/.env
|
||||||
|
# is already reserved for the registration token above and gets overwritten
|
||||||
|
# on every deploy.
|
||||||
services:
|
services:
|
||||||
%{ for i in range(runner_count) ~}
|
%{ for i in range(runner_count) ~}
|
||||||
runner-${i + 1}:
|
runner-${i + 1}:
|
||||||
@@ -13,7 +18,7 @@ services:
|
|||||||
container_name: gitea_runner_${i + 1}
|
container_name: gitea_runner_${i + 1}
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config.yaml
|
- ./config.yaml:/config.yaml
|
||||||
- ./gitea_runner_${i + 1}:/data
|
- ${data_dir}/gitea_runner_${i + 1}:/data
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|||||||
@@ -57,3 +57,8 @@ variable "runner_count" {
|
|||||||
description = "Number of Gitea Actions runner containers to render into services/dev/Runners/docker-compose.yml."
|
description = "Number of Gitea Actions runner containers to render into services/dev/Runners/docker-compose.yml."
|
||||||
type = number
|
type = number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "deploy_user" {
|
||||||
|
description = "Non-root sudo user created by the bootstrap script - services/dev is copied into this user's home directory."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,3 +120,75 @@ resource "hcloud_volume" "storage" {
|
|||||||
prevent_destroy = true
|
prevent_destroy = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
# Hetzner's automount convention (hc-utils' 99-hc-volume-automount.rules) is
|
||||||
|
# always /mnt/HC_Volume_<id> - deterministic once the volume exists, since the
|
||||||
|
# id is stable for the volume's lifetime regardless of which server it's
|
||||||
|
# attached to.
|
||||||
|
data_dir = "/mnt/HC_Volume_${hcloud_volume.storage.id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# DATA_DIR is picked up automatically by `docker compose` (which auto-loads
|
||||||
|
# .env from its working directory) for every prod/*-docker-compose.yml bind
|
||||||
|
# mount - see services/prod/*.yml. Not committed (see .gitignore): it's tied to
|
||||||
|
# the live volume's ID, so it's regenerated by tofu apply, not handed off via git.
|
||||||
|
resource "local_file" "env" {
|
||||||
|
filename = "${path.root}/../services/prod/.env"
|
||||||
|
content = "DATA_DIR=${local.data_dir}\n"
|
||||||
|
file_permission = "0644"
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
# Everything under services/prod except .env: that's tracked via its own
|
||||||
|
# resource content (reading it back with fileset()/filesha1() before it
|
||||||
|
# exists would fail during `tofu plan`).
|
||||||
|
prod_static_files = sort([
|
||||||
|
for f in fileset("${path.root}/../services/prod", "**") : f if f != ".env"
|
||||||
|
])
|
||||||
|
|
||||||
|
prod_static_files_hash = sha1(join("", [
|
||||||
|
for f in local.prod_static_files : filesha1("${path.root}/../services/prod/${f}")
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copies services/prod to the server on every apply that changes it (a
|
||||||
|
# hand-edited compose file, ...) or recreates the server - not just once at
|
||||||
|
# first creation. Split from hcloud_server.this because it must run after
|
||||||
|
# local_file.env, which in turn depends on the volume, which depends on the
|
||||||
|
# server - so it can't be a provisioner on the server resource itself.
|
||||||
|
resource "null_resource" "deploy_services" {
|
||||||
|
triggers = {
|
||||||
|
server_id = hcloud_server.this.id
|
||||||
|
static_files = local.prod_static_files_hash
|
||||||
|
env = local_file.env.content
|
||||||
|
}
|
||||||
|
|
||||||
|
connection {
|
||||||
|
type = "ssh"
|
||||||
|
host = hcloud_server.this.ipv4_address
|
||||||
|
user = "root"
|
||||||
|
private_key = file(var.ssh_private_key_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "remote-exec" {
|
||||||
|
inline = ["mkdir -p /home/${var.deploy_user}/services"]
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "file" {
|
||||||
|
source = "${path.root}/../services/prod"
|
||||||
|
destination = "/home/${var.deploy_user}/services"
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "remote-exec" {
|
||||||
|
inline = [
|
||||||
|
"chown -R ${var.deploy_user}:${var.deploy_user} /home/${var.deploy_user}/services",
|
||||||
|
"chmod +x /home/${var.deploy_user}/services/prod/*.sh",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
hcloud_server.this,
|
||||||
|
local_file.env,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,3 +5,8 @@ output "ipv4" {
|
|||||||
output "private_ipv4" {
|
output "private_ipv4" {
|
||||||
value = var.private_ip
|
value = var.private_ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output "data_dir" {
|
||||||
|
description = "Deterministic host path of the auto-mounted volume - see local.data_dir."
|
||||||
|
value = local.data_dir
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,3 +52,8 @@ variable "ssh_private_key_path" {
|
|||||||
description = "Local path to the private key matching one of var.ssh_key_ids, used to run the bootstrap script over SSH."
|
description = "Local path to the private key matching one of var.ssh_key_ids, used to run the bootstrap script over SSH."
|
||||||
type = string
|
type = string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "deploy_user" {
|
||||||
|
description = "Non-root sudo user created by the bootstrap script - services/prod is copied into this user's home directory."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,3 +58,47 @@ resource "hcloud_server" "this" {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
vpn_static_files = sort(fileset("${path.root}/../services/vpn", "**"))
|
||||||
|
|
||||||
|
vpn_static_files_hash = sha1(join("", [
|
||||||
|
for f in local.vpn_static_files : filesha1("${path.root}/../services/vpn/${f}")
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copies services/vpn to the server on every apply that changes it (a
|
||||||
|
# hand-edited compose file, ...) or recreates the server - not just once at
|
||||||
|
# first creation. Split from hcloud_server.this so it's triggered by content
|
||||||
|
# changes independently of the bootstrap provisioners above.
|
||||||
|
resource "null_resource" "deploy_services" {
|
||||||
|
triggers = {
|
||||||
|
server_id = hcloud_server.this.id
|
||||||
|
static_files = local.vpn_static_files_hash
|
||||||
|
}
|
||||||
|
|
||||||
|
connection {
|
||||||
|
type = "ssh"
|
||||||
|
host = hcloud_server.this.ipv4_address
|
||||||
|
user = "root"
|
||||||
|
private_key = file(var.ssh_private_key_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "remote-exec" {
|
||||||
|
inline = ["mkdir -p /home/${var.deploy_user}/services"]
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "file" {
|
||||||
|
source = "${path.root}/../services/vpn"
|
||||||
|
destination = "/home/${var.deploy_user}/services"
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "remote-exec" {
|
||||||
|
inline = [
|
||||||
|
"chown -R ${var.deploy_user}:${var.deploy_user} /home/${var.deploy_user}/services",
|
||||||
|
"chmod +x /home/${var.deploy_user}/services/vpn/*.sh",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [hcloud_server.this]
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,3 +32,8 @@ variable "ssh_private_key_path" {
|
|||||||
description = "Local path to the private key matching one of var.ssh_key_ids, used to run the bootstrap script over SSH."
|
description = "Local path to the private key matching one of var.ssh_key_ids, used to run the bootstrap script over SSH."
|
||||||
type = string
|
type = string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "deploy_user" {
|
||||||
|
description = "Non-root sudo user created by the bootstrap script - services/vpn is copied into this user's home directory."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ output "prod_private_ipv4" {
|
|||||||
value = module.prod.private_ipv4
|
value = module.prod.private_ipv4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output "dev_data_dir" {
|
||||||
|
description = "Deterministic host path of dev's auto-mounted volume - also written to services/dev/.env as DATA_DIR."
|
||||||
|
value = module.dev.data_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
output "prod_data_dir" {
|
||||||
|
description = "Deterministic host path of prod's auto-mounted volume - also written to services/prod/.env as DATA_DIR."
|
||||||
|
value = module.prod.data_dir
|
||||||
|
}
|
||||||
|
|
||||||
output "vpn_ipv4" {
|
output "vpn_ipv4" {
|
||||||
value = module.vpn.ipv4
|
value = module.vpn.ipv4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ terraform {
|
|||||||
source = "hashicorp/local"
|
source = "hashicorp/local"
|
||||||
version = "~> 2.5"
|
version = "~> 2.5"
|
||||||
}
|
}
|
||||||
|
null = {
|
||||||
|
source = "hashicorp/null"
|
||||||
|
version = "~> 3.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ architecture-beta
|
|||||||
│ │ └── bootstrap.sh.tftpl # post-install script run on every host right after creation
|
│ │ └── bootstrap.sh.tftpl # post-install script run on every host right after creation
|
||||||
│ └── modules/
|
│ └── modules/
|
||||||
│ ├── network/ # shared private network + subnet (used by dev and prod)
|
│ ├── network/ # shared private network + subnet (used by dev and prod)
|
||||||
│ ├── dev/ # dev server + firewall + volume + renders services/dev/Runners/docker-compose.yml
|
│ ├── dev/ # dev server + firewall + volume, renders + deploys services/dev/
|
||||||
│ │ └── templates/
|
│ │ └── templates/
|
||||||
│ │ └── runners-docker-compose.yml.tftpl # shape of each generated runner service
|
│ │ └── runners-docker-compose.yml.tftpl # shape of each generated runner service
|
||||||
│ ├── prod/ # prod server + firewall + volume
|
│ ├── prod/ # prod server + firewall + volume, deploys services/prod/
|
||||||
│ ├── vpn/ # vpn server + firewall (no private network, no volume)
|
│ ├── vpn/ # vpn server + firewall (no private network, no volume), deploys services/vpn/
|
||||||
│ └── dns/ # Hetzner DNS zones + A records for every domain in var.dns_zones
|
│ └── dns/ # Hetzner DNS zones + A records for every domain in var.dns_zones
|
||||||
├── services/ # Docker Compose stacks, grouped by which server they run on
|
├── services/ # Docker Compose stacks, grouped by which server they run on
|
||||||
│ ├── dev/ # Gitea + CI runner + Traefik (git.luke-else.co.uk, cicd.luke-else.co.uk)
|
│ ├── dev/ # Gitea + CI runner + Traefik (git.luke-else.co.uk, cicd.luke-else.co.uk)
|
||||||
@@ -124,7 +124,7 @@ This creates, via `module.network` / `module.dev` / `module.prod` / `module.vpn`
|
|||||||
- one `hcloud_volume` each for `dev` and `prod` (`modules/dev`, `modules/prod`; `vpn` has none)
|
- one `hcloud_volume` each for `dev` and `prod` (`modules/dev`, `modules/prod`; `vpn` has none)
|
||||||
- one Hetzner DNS zone per domain in `var.dns_zones`, plus every A record in [Service inventory](#service-inventory) (`modules/dns` — see [Managing DNS](#managing-dns))
|
- one Hetzner DNS zone per domain in `var.dns_zones`, plus every A record in [Service inventory](#service-inventory) (`modules/dns` — see [Managing DNS](#managing-dns))
|
||||||
|
|
||||||
Useful outputs: `tofu output dev_ipv4`, `tofu output prod_ipv4`, `tofu output vpn_ipv4`, `tofu output dns_nameservers`.
|
Useful outputs: `tofu output dev_ipv4`, `tofu output prod_ipv4`, `tofu output vpn_ipv4`, `tofu output dns_nameservers`, `tofu output dev_data_dir`, `tofu output prod_data_dir`.
|
||||||
|
|
||||||
`terraform.tfvars` and any `*.tfvars` file are gitignored — never commit real values there. Defaults for server sizes, locations, and IP ranges live in `infra/variables.tf` and are passed down into the modules from `infra/main.tf`; override them per-environment via `terraform.tfvars`.
|
`terraform.tfvars` and any `*.tfvars` file are gitignored — never commit real values there. Defaults for server sizes, locations, and IP ranges live in `infra/variables.tf` and are passed down into the modules from `infra/main.tf`; override them per-environment via `terraform.tfvars`.
|
||||||
|
|
||||||
@@ -139,6 +139,16 @@ Immediately after each `hcloud_server` is created, OpenTofu uploads and runs [`i
|
|||||||
|
|
||||||
After bootstrap, SSH in as `var.deploy_user` for day-to-day work — root key-based login still works as a fallback.
|
After bootstrap, SSH in as `var.deploy_user` for day-to-day work — root key-based login still works as a fallback.
|
||||||
|
|
||||||
|
### Persistent data lives on the volumes, not the server disk
|
||||||
|
|
||||||
|
`dev` and `prod`'s compose files used to bind-mount container data to paths relative to wherever `services/<host>/` happened to be copied on the server (e.g. `./gitea:/data`) - i.e. the server's local disk, which doesn't survive the server being destroyed and recreated. `dev-storage` and `prod-storage` (the `hcloud_volume`s) do survive that, so all stateful bind mounts now point there instead: `${DATA_DIR}/gitea:/data`, `${DATA_DIR}/bitwarden/:/data/`, and so on across `services/dev/*.yml` and `services/prod/*.yml`. `vpn` has no volume, so it's untouched - see the architecture table above.
|
||||||
|
|
||||||
|
`DATA_DIR` needing to resolve to the right path is what makes this work, and that's answered deterministically: Hetzner's automount tooling always mounts an auto-mounted volume at `/mnt/HC_Volume_<volume-id>`, and since the volume is a Terraform resource, `modules/<host>/main.tf` already knows its `id` the moment it's created - `local.data_dir = "/mnt/HC_Volume_${hcloud_volume.storage.id}"`. Every `tofu apply` writes that value straight into `services/dev/.env` / `services/prod/.env` as `DATA_DIR=...`, which `docker compose` auto-loads for variable substitution in any compose file run from that directory (the same mechanism the Gitea runner token already uses). Query it directly with `tofu output dev_data_dir` / `tofu output prod_data_dir`.
|
||||||
|
|
||||||
|
These `.env` files are deliberately not committed (see `.gitignore`): the path is tied to the *live* volume's ID, so it's regenerated by `tofu apply`, not handed off via git. Run `tofu apply` at least once before the first deploy, and again if a volume is ever destroyed and recreated (new ID); `spinup.sh` on both hosts refuses to start anything if `.env` is missing, rather than silently falling back to a relative path.
|
||||||
|
|
||||||
|
The Gitea Actions runners are the one exception to the `.env` mechanism: their `/data` path is baked directly into the generated `Runners/docker-compose.yml` at render time (via the same `templatefile()` call as `dev_runner_count`), because `Runners/.env` is already reserved for the registration token and gets overwritten on every deploy.
|
||||||
|
|
||||||
### Scaling Gitea Actions runners
|
### Scaling Gitea Actions runners
|
||||||
|
|
||||||
The number of Gitea Actions runner containers on `dev` is an OpenTofu input, `var.dev_runner_count` (default `3`). On every `tofu apply`, `modules/dev`'s `local_file.runners_compose` resource renders [`modules/dev/templates/runners-docker-compose.yml.tftpl`](infra/modules/dev/templates/runners-docker-compose.yml.tftpl) straight into [`services/dev/Runners/docker-compose.yml`](services/dev/Runners/docker-compose.yml) in your working tree — one `runner-N` service per count, each with its own container name and `/data` volume so their registrations don't collide. That file is generated: change `dev_runner_count` in `terraform.tfvars` and re-run `tofu apply` rather than hand-editing it.
|
The number of Gitea Actions runner containers on `dev` is an OpenTofu input, `var.dev_runner_count` (default `3`). On every `tofu apply`, `modules/dev`'s `local_file.runners_compose` resource renders [`modules/dev/templates/runners-docker-compose.yml.tftpl`](infra/modules/dev/templates/runners-docker-compose.yml.tftpl) straight into [`services/dev/Runners/docker-compose.yml`](services/dev/Runners/docker-compose.yml) in your working tree — one `runner-N` service per count, each with its own container name and `/data` volume so their registrations don't collide. That file is generated: change `dev_runner_count` in `terraform.tfvars` and re-run `tofu apply` rather than hand-editing it.
|
||||||
@@ -151,9 +161,9 @@ Registration tokens are handled automatically, not baked into the generated file
|
|||||||
|
|
||||||
`dev` and `prod`'s firewalls only accept SSH from `vpn`'s public IP (see [Security notes](#security-notes)), but `vpn`'s own OpenVPN service isn't running until you deploy it — so on a from-scratch `tofu apply`, the `dev`/`prod` bootstrap provisioners can't connect yet. Bring the estate up in this order:
|
`dev` and `prod`'s firewalls only accept SSH from `vpn`'s public IP (see [Security notes](#security-notes)), but `vpn`'s own OpenVPN service isn't running until you deploy it — so on a from-scratch `tofu apply`, the `dev`/`prod` bootstrap provisioners can't connect yet. Bring the estate up in this order:
|
||||||
|
|
||||||
1. `tofu apply -target=module.vpn` — creates `vpn` only; its firewall still allows SSH from `var.allowed_ssh_source_ips`, so its own bootstrap provisioner runs fine.
|
1. `tofu apply -target=module.vpn` — creates `vpn` only; its firewall still allows SSH from `var.allowed_ssh_source_ips`, so its bootstrap provisioner and `null_resource.deploy_services` (which copies `services/vpn` over) both run fine.
|
||||||
2. Deploy and start the VPN service on `vpn` (see [Deploying the services](#deploying-the-services-services)), then connect to it with an OpenVPN client.
|
2. SSH in as `var.deploy_user` and run `~/services/vpn/spinup.sh` to start the VPN service, then connect to it with an OpenVPN client.
|
||||||
3. `tofu apply` — creates `network`, `dev`, `prod`. Now that your machine is tunneled through `vpn`, its NATed egress IP matches the firewall rule and the `dev`/`prod` bootstrap provisioners can connect.
|
3. `tofu apply` — creates `network`, `dev`, `prod`. Now that your machine is tunneled through `vpn`, its NATed egress IP matches the firewall rule and the `dev`/`prod` bootstrap + deploy provisioners can connect.
|
||||||
|
|
||||||
If step 3 is run before you're connected to the VPN, the `dev`/`prod` provisioners will fail and Terraform will mark those servers tainted — re-running `tofu apply` once connected will recreate and re-bootstrap them.
|
If step 3 is run before you're connected to the VPN, the `dev`/`prod` provisioners will fail and Terraform will mark those servers tainted — re-running `tofu apply` once connected will recreate and re-bootstrap them.
|
||||||
|
|
||||||
@@ -169,7 +179,7 @@ Only A records for IPv4 are managed here — none of the modules currently track
|
|||||||
|
|
||||||
## Deploying the services (`services/`)
|
## Deploying the services (`services/`)
|
||||||
|
|
||||||
Once a server exists, copy the relevant `services/<host>/` directory to it (e.g. `scp -r services/prod user@<prod_ipv4>:~/services`) and run the matching script from inside that directory:
|
`tofu apply` already puts `services/<host>/` on the matching server for you — a `null_resource.deploy_services` in each `modules/<host>/main.tf` copies it to `/home/<var.deploy_user>/services/<host>` and `chown`s it to `var.deploy_user`, re-running whenever the server is recreated or the directory's contents change (a hand-edited compose file, a new `dev_runner_count`, a refreshed `DATA_DIR`, ...) - not just once at first creation. So after applying, just SSH in as `var.deploy_user` and run the matching script from inside that directory:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# on dev
|
# on dev
|
||||||
@@ -185,6 +195,8 @@ Once a server exists, copy the relevant `services/<host>/` directory to it (e.g.
|
|||||||
./spindown.sh
|
./spindown.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you ever need to force a re-sync without going through Terraform (e.g. testing a local edit before committing it), a manual `scp -r services/prod deploy@<prod_ipv4>:~/services` still works fine - `tofu apply` will just overwrite it again next time triggers change.
|
||||||
|
|
||||||
Each stack can also be managed individually with plain Compose, e.g.:
|
Each stack can also be managed individually with plain Compose, e.g.:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -6,13 +6,18 @@
|
|||||||
# GITEA_RUNNER_REGISTRATION_TOKEN is intentionally left as a compose variable
|
# GITEA_RUNNER_REGISTRATION_TOKEN is intentionally left as a compose variable
|
||||||
# (not baked in here) - services/dev/spinup.sh generates a fresh token and
|
# (not baked in here) - services/dev/spinup.sh generates a fresh token and
|
||||||
# writes it to Runners/.env immediately before starting these containers.
|
# writes it to Runners/.env immediately before starting these containers.
|
||||||
|
#
|
||||||
|
# /data lives on dev's volume (/mnt/HC_Volume_PENDING_TOFU_APPLY) so runner
|
||||||
|
# identity survives a server rebuild - baked in directly rather than via .env,
|
||||||
|
# since Runners/.env is already reserved for the registration token above and
|
||||||
|
# gets overwritten on every deploy.
|
||||||
services:
|
services:
|
||||||
runner-1:
|
runner-1:
|
||||||
image: gitea/act_runner:latest
|
image: gitea/act_runner:latest
|
||||||
container_name: gitea_runner_1
|
container_name: gitea_runner_1
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config.yaml
|
- ./config.yaml:/config.yaml
|
||||||
- ./gitea_runner_1:/data
|
- /mnt/HC_Volume_PENDING_TOFU_APPLY/gitea_runner_1:/data
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ services:
|
|||||||
image: gitea/gitea:latest
|
image: gitea/gitea:latest
|
||||||
container_name: gitea
|
container_name: gitea
|
||||||
volumes:
|
volumes:
|
||||||
- ./gitea:/data
|
- ${DATA_DIR}/gitea:/data
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Missing .env (expected DATA_DIR=/mnt/HC_Volume_...) - run 'tofu apply' in infra/ first, then re-copy this directory." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
docker compose -f traefik-docker-compose.yml pull && docker compose -f traefik-docker-compose.yml up -d
|
docker compose -f traefik-docker-compose.yml pull && docker compose -f traefik-docker-compose.yml up -d
|
||||||
|
|
||||||
docker compose -f gitea-docker-compose.yml pull && docker compose -f gitea-docker-compose.yml up -d
|
docker compose -f gitea-docker-compose.yml pull && docker compose -f gitea-docker-compose.yml up -d
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- "./letsencrypt:/letsencrypt"
|
- "${DATA_DIR}/letsencrypt:/letsencrypt"
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ services:
|
|||||||
image: "vaultwarden/server:latest"
|
image: "vaultwarden/server:latest"
|
||||||
container_name: vaultwarden
|
container_name: vaultwarden
|
||||||
volumes:
|
volumes:
|
||||||
- ./bitwarden/:/data/
|
- ${DATA_DIR}/bitwarden/:/data/
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/root
|
- ${DATA_DIR}/rustdesk:/root
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/root
|
- ${DATA_DIR}/rustdesk:/root
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Missing .env (expected DATA_DIR=/mnt/HC_Volume_...) - run 'tofu apply' in infra/ first, then re-copy this directory." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
docker compose -f traefik-docker-compose.yml pull && docker compose -f traefik-docker-compose.yml up -d
|
docker compose -f traefik-docker-compose.yml pull && docker compose -f traefik-docker-compose.yml up -d
|
||||||
|
|
||||||
sleep 20 # Allow Traefik + registry auth to settle before starting the rest of the services
|
sleep 20 # Allow Traefik + registry auth to settle before starting the rest of the services
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ services:
|
|||||||
image: louislam/uptime-kuma:latest
|
image: louislam/uptime-kuma:latest
|
||||||
container_name: status
|
container_name: status
|
||||||
volumes:
|
volumes:
|
||||||
- ./uptime-kuma/data:/app/data
|
- ${DATA_DIR}/uptime-kuma/data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
- "443:443"
|
- "443:443"
|
||||||
- "27017:27017"
|
- "27017:27017"
|
||||||
volumes:
|
volumes:
|
||||||
- "./letsencrypt:/letsencrypt"
|
- "${DATA_DIR}/letsencrypt:/letsencrypt"
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
image: "php:apache"
|
image: "php:apache"
|
||||||
container_name: snexo
|
container_name: snexo
|
||||||
volumes:
|
volumes:
|
||||||
- ./snexo.co.uk/:/var/www/html
|
- ${DATA_DIR}/snexo.co.uk/:/var/www/html
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
Reference in New Issue
Block a user