diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..3650739 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: Deploy + +on: + push: + branches: [main] + +env: + SERVER_IP: ${{ secrets.SERVER_IP }} + SSH_USER: drb + +jobs: + deploy: + name: Deploy to VM + runs-on: ubuntu-latest + + steps: + - name: Write SSH key + run: | + echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key + chmod 600 /tmp/deploy_key + + - name: Deploy + run: | + ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key \ + ${{ env.SSH_USER }}@${{ env.SERVER_IP }} << 'ENDSSH' + set -e + cd /opt/drb + + # Pull latest code + git pull origin main + + # Rebuild and restart changed services + docker compose up -d --build --remove-orphans + + # Reload Caddy if Caddyfile changed + sudo systemctl reload caddy + + # Clean up old images + docker image prune -f + ENDSSH + + - name: Verify health + run: | + sleep 15 + curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \ + (echo "Health check failed" && exit 1) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..a1b8223 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,26 @@ +# Production overrides — used on the VM. +# Run with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# +# Differences from dev: +# - MQTT port 1883 is NOT published to the host (stays on the Docker bridge). +# Edge nodes reach it via WireGuard tunnel to the Docker bridge IP. +# - c2-core and frontend ports are only bound to localhost (Caddy proxies them). +# - restart: always (instead of unless-stopped) for hard reboots. + +services: + mosquitto: + restart: always + ports: !reset [] # Remove the dev 1883:1883 mapping — internal only + + c2-core: + restart: always + ports: + - "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly + + discord-bot: + restart: always + + frontend: + restart: always + ports: + - "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly diff --git a/infra/Caddyfile b/infra/Caddyfile new file mode 100644 index 0000000..6220bb8 --- /dev/null +++ b/infra/Caddyfile @@ -0,0 +1,14 @@ +# Managed by CI — deployed to /etc/caddy/Caddyfile on the server. +# Caddy handles TLS automatically via Let's Encrypt. + +api.{$DRB_DOMAIN} { + reverse_proxy localhost:8888 { + header_up X-Forwarded-For {remote_host} + } +} + +app.{$DRB_DOMAIN} { + reverse_proxy localhost:3000 { + header_up X-Forwarded-For {remote_host} + } +} diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..79eb02a --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,138 @@ +terraform { + required_version = ">= 1.6" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + } + + # Store state in GCS — create the bucket manually once before first apply + backend "gcs" { + bucket = "drb-tf-state" + prefix = "drb/state" + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +# --------------------------------------------------------------------------- +# Static external IP +# --------------------------------------------------------------------------- + +resource "google_compute_address" "drb" { + name = "drb-server-ip" + region = var.region +} + +# --------------------------------------------------------------------------- +# Firewall rules +# --------------------------------------------------------------------------- + +resource "google_compute_firewall" "allow_web" { + name = "drb-allow-web" + network = "default" + + allow { + protocol = "tcp" + ports = ["80", "443"] + } + + source_ranges = ["0.0.0.0/0"] + target_tags = ["drb-server"] +} + +resource "google_compute_firewall" "allow_ssh" { + name = "drb-allow-ssh" + network = "default" + + allow { + protocol = "tcp" + ports = ["22"] + } + + # Restrict SSH to your IP(s) and Gitea runner IP + source_ranges = var.allowed_ssh_cidrs + target_tags = ["drb-server"] +} + +# MQTT is NOT exposed externally — edge nodes connect via WireGuard (see below) +# If you need to temporarily allow direct MQTT access for testing, uncomment and +# restrict source_ranges to your node IPs. +# +# resource "google_compute_firewall" "allow_mqtt" { +# name = "drb-allow-mqtt" +# network = "default" +# allow { +# protocol = "tcp" +# ports = ["8883"] # TLS MQTT, not 1883 +# } +# source_ranges = ["YOUR_NODE_CIDR"] +# target_tags = ["drb-server"] +# } + +# --------------------------------------------------------------------------- +# Compute Engine VM +# --------------------------------------------------------------------------- + +resource "google_compute_instance" "drb_server" { + name = "drb-server" + machine_type = var.machine_type + zone = var.zone + tags = ["drb-server"] + + boot_disk { + initialize_params { + image = "debian-cloud/debian-12" + size = 30 # GB — enough for Docker images + mosquitto data + type = "pd-balanced" + } + } + + network_interface { + network = "default" + access_config { + nat_ip = google_compute_address.drb.address + } + } + + metadata = { + ssh-keys = "${var.ssh_user}:${var.ssh_public_key}" + } + + # Startup script runs once on first boot to install Docker + Caddy + metadata_startup_script = file("${path.module}/startup.sh") + + # Allow the VM to pull from Artifact Registry using its service account + service_account { + scopes = ["cloud-platform"] + } + + lifecycle { + # Prevent Terraform from destroying + recreating on metadata changes + ignore_changes = [metadata_startup_script] + } +} + +# --------------------------------------------------------------------------- +# Cloud DNS records +# --------------------------------------------------------------------------- + +resource "google_dns_record_set" "app" { + name = "app.${var.domain}." + type = "A" + ttl = 300 + managed_zone = var.dns_zone_name + rrdatas = [google_compute_address.drb.address] +} + +resource "google_dns_record_set" "api" { + name = "api.${var.domain}." + type = "A" + ttl = 300 + managed_zone = var.dns_zone_name + rrdatas = [google_compute_address.drb.address] +} diff --git a/infra/outputs.tf b/infra/outputs.tf new file mode 100644 index 0000000..33ecbbb --- /dev/null +++ b/infra/outputs.tf @@ -0,0 +1,12 @@ +output "server_ip" { + value = google_compute_address.drb.address + description = "Static external IP of the DRB server VM" +} + +output "app_url" { + value = "https://app.${var.domain}" +} + +output "api_url" { + value = "https://api.${var.domain}" +} diff --git a/infra/startup.sh b/infra/startup.sh new file mode 100644 index 0000000..8030a65 --- /dev/null +++ b/infra/startup.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Runs once on first VM boot. Installs Docker, Docker Compose, and Caddy. +set -euxo pipefail + +# ── Docker ──────────────────────────────────────────────────────────────────── +apt-get update -y +apt-get install -y ca-certificates curl gnupg lsb-release + +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg + +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/debian $(lsb_release -cs) stable" \ + > /etc/apt/sources.list.d/docker.list + +apt-get update -y +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +systemctl enable docker +systemctl start docker + +# Allow drb user to run docker +usermod -aG docker drb 2>/dev/null || true + +# ── Caddy (reverse proxy + auto TLS) ───────────────────────────────────────── +apt-get install -y debian-keyring debian-archive-keyring apt-transport-https +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + > /etc/apt/sources.list.d/caddy-stable.list +apt-get update -y +apt-get install -y caddy + +# ── App directory — clone repo so CI can git pull + docker compose up ───────── +apt-get install -y git +mkdir -p /opt/drb +# Repo is cloned here by initial setup; CI just git pulls and rebuilds. +# Set safe directory for the drb user +git config --global --add safe.directory /opt/drb +chown -R drb:drb /opt/drb 2>/dev/null || true + +# ── Caddyfile placeholder (CI will write the real one on first deploy) ──────── +cat > /etc/caddy/Caddyfile <<'CADDY' +# This file is managed by CI. Do not edit manually. +# It will be replaced on the first deployment. +:80 { + respond "DRB server — waiting for deployment" 200 +} +CADDY + +systemctl enable caddy +systemctl reload caddy + +echo "Startup complete." diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..51991cd --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,48 @@ +variable "project_id" { + description = "GCP project ID" + type = string +} + +variable "region" { + description = "GCP region" + type = string + default = "us-central1" +} + +variable "zone" { + description = "GCP zone" + type = string + default = "us-central1-a" +} + +variable "domain" { + description = "Base domain (e.g. example.com)" + type = string +} + +variable "dns_zone_name" { + description = "Cloud DNS managed zone name" + type = string +} + +variable "machine_type" { + description = "Compute Engine machine type" + type = string + default = "e2-small" +} + +variable "ssh_user" { + description = "SSH username for the VM" + type = string + default = "drb" +} + +variable "ssh_public_key" { + description = "SSH public key to authorize on the VM" + type = string +} + +variable "allowed_ssh_cidrs" { + description = "CIDR ranges allowed to SSH to the VM (your IP + Gitea runner)" + type = list(string) +}