feat(tf-services): shared services droplet (3 Forgejo + Verdaccio) module

DO droplet (nyc3 s-2vcpu-4gb + swap) running 3 co-located Forgejo (ct/mc/quinn)
+ Verdaccio via docker-compose. HTTP+token (built-in SSH disabled). Provisioned
165.227.191.38; state local (gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
quinn 2026-06-29 18:20:43 -04:00
commit 870bb55174
5 changed files with 207 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
terraform.tfvars

72
cloud-init.yaml Normal file
View file

@ -0,0 +1,72 @@
#cloud-config
package_update: true
packages:
- docker.io
- docker-compose-v2
write_files:
- path: /opt/services/docker-compose.yml
permissions: "0644"
content: |
services:
forgejo-ct:
image: codeberg.org/forgejo/forgejo:10
restart: always
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__HTTP_PORT: "3000"
FORGEJO__server__SSH_PORT: "2222"
FORGEJO__security__INSTALL_LOCK: "true"
FORGEJO__service__DISABLE_REGISTRATION: "true"
volumes:
- /opt/services/ct:/data
ports:
- "3000:3000"
- "2222:22"
forgejo-mc:
image: codeberg.org/forgejo/forgejo:10
restart: always
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__HTTP_PORT: "3000"
FORGEJO__server__SSH_PORT: "2223"
FORGEJO__security__INSTALL_LOCK: "true"
FORGEJO__service__DISABLE_REGISTRATION: "true"
volumes:
- /opt/services/mc:/data
ports:
- "3001:3000"
- "2223:22"
forgejo-quinn:
image: codeberg.org/forgejo/forgejo:10
restart: always
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__HTTP_PORT: "3000"
FORGEJO__server__SSH_PORT: "2224"
FORGEJO__security__INSTALL_LOCK: "true"
FORGEJO__service__DISABLE_REGISTRATION: "true"
volumes:
- /opt/services/quinn:/data
ports:
- "3002:3000"
- "2224:22"
verdaccio:
image: verdaccio/verdaccio:6
restart: always
ports:
- "4873:4873"
volumes:
- /opt/services/verdaccio:/verdaccio/storage
runcmd:
# 2GB swap (safety on the 4GB box)
- [ bash, -c, "fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' >> /etc/fstab" ]
- [ bash, -c, "mkdir -p /opt/services/ct /opt/services/mc /opt/services/quinn /opt/services/verdaccio && chown -R 1000:1000 /opt/services" ]
- [ systemctl, enable, --now, docker ]
- [ bash, -c, "cd /opt/services && docker compose up -d" ]
final_message: "services droplet up: 3 Forgejo (ct:3000 mc:3001 quinn:3002) + Verdaccio:4873"

88
main.tf Normal file
View file

@ -0,0 +1,88 @@
###############################################################################
# Shared services droplet 3 Forgejo instances (ct/mc/quinn) + Verdaccio,
# co-located. Public-facing (like the current ct-forge). 4GB + swap (tight but
# fine for low-traffic git + npm). PyPI / SwiftPM / DNS / Caddy are fast-follow.
###############################################################################
resource "digitalocean_droplet" "services" {
name = var.name
image = "ubuntu-24-04-x64"
size = var.droplet_size
region = var.region
ssh_keys = var.ssh_key_fingerprints
tags = ["services", "forge", "quinn-infra"]
user_data = file("${path.module}/cloud-init.yaml")
lifecycle {
# Forgejo/Verdaccio data lives in /opt/services volumes; never let a
# user_data tweak silently rebuild and wipe it.
ignore_changes = [user_data]
}
}
resource "digitalocean_firewall" "services" {
name = "services-fw"
droplet_ids = [digitalocean_droplet.services.id]
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# Forgejo HTTP (ct 3000 / mc 3001 / quinn 3002) + git-SSH (2222/2223/2224) + Verdaccio 4873
inbound_rule {
protocol = "tcp"
port_range = "3000-3002"
source_addresses = ["0.0.0.0/0", "::/0"]
}
inbound_rule {
protocol = "tcp"
port_range = "2222-2224"
source_addresses = ["0.0.0.0/0", "::/0"]
}
inbound_rule {
protocol = "tcp"
port_range = "4873"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# 80/443 for future Caddy/TLS
inbound_rule {
protocol = "tcp"
port_range = "80"
source_addresses = ["0.0.0.0/0", "::/0"]
}
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "icmp"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
}
output "services_ip" {
value = digitalocean_droplet.services.ipv4_address
}
output "forge_urls" {
value = {
ct = "http://${digitalocean_droplet.services.ipv4_address}:3000"
mc = "http://${digitalocean_droplet.services.ipv4_address}:3001"
quinn = "http://${digitalocean_droplet.services.ipv4_address}:3002"
npm = "http://${digitalocean_droplet.services.ipv4_address}:4873"
}
}

29
variables.tf Normal file
View file

@ -0,0 +1,29 @@
variable "do_token" {
type = string
sensitive = true
description = "DigitalOcean PAT (ct project)."
}
variable "region" {
type = string
default = "nyc3"
}
variable "droplet_size" {
type = string
default = "s-2vcpu-4gb"
}
variable "ssh_key_fingerprints" {
type = list(string)
description = "DO SSH key fingerprints authorized on the box."
default = [
"00:b5:2c:23:67:43:e5:39:c9:c2:43:31:6e:5c:03:10", # plum-natalie (operator laptop)
"b2:7e:66:b1:9b:61:ac:69:c5:96:a9:97:34:5c:9b:db", # cocotte-fleet
]
}
variable "name" {
type = string
default = "services"
}

13
versions.tf Normal file
View file

@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.5"
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.40"
}
}
}
provider "digitalocean" {
token = var.do_token
}