Initial commit - realms platform

This commit is contained in:
doomtube 2026-01-05 22:54:27 -05:00
parent c590ab6d18
commit c717c3751c
234 changed files with 74103 additions and 15231 deletions

View file

@ -0,0 +1,187 @@
# =============================================================================
# Firewalls Module
# Defense in depth with DigitalOcean Cloud Firewalls
# =============================================================================
# -----------------------------------------------------------------------------
# Jump Host Firewall
# Only allows SSH on non-standard port from anywhere
# -----------------------------------------------------------------------------
resource "digitalocean_firewall" "jump_host" {
name = "${var.project_name}-${var.environment}-jump-fw"
droplet_ids = [var.jump_host_droplet_id]
# Inbound: SSH on non-standard port
inbound_rule {
protocol = "tcp"
port_range = var.jump_host_ssh_port
source_addresses = ["0.0.0.0/0", "::/0"]
}
# Inbound: Allow all VPC traffic
inbound_rule {
protocol = "tcp"
port_range = "1-65535"
source_addresses = [var.vpc_ip_range]
}
inbound_rule {
protocol = "udp"
port_range = "1-65535"
source_addresses = [var.vpc_ip_range]
}
inbound_rule {
protocol = "icmp"
source_addresses = [var.vpc_ip_range]
}
# Outbound: Only necessary traffic (security hardening)
outbound_rule {
protocol = "tcp"
port_range = "53"
destination_addresses = ["0.0.0.0/0", "::/0"] # DNS
}
outbound_rule {
protocol = "udp"
port_range = "53"
destination_addresses = ["0.0.0.0/0", "::/0"] # DNS
}
outbound_rule {
protocol = "tcp"
port_range = "80"
destination_addresses = ["0.0.0.0/0", "::/0"] # HTTP (apt)
}
outbound_rule {
protocol = "tcp"
port_range = "443"
destination_addresses = ["0.0.0.0/0", "::/0"] # HTTPS
}
outbound_rule {
protocol = "udp"
port_range = "123"
destination_addresses = ["0.0.0.0/0", "::/0"] # NTP
}
outbound_rule {
protocol = "icmp"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
# VPC outbound (all ports for internal communication)
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = [var.vpc_ip_range]
}
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = [var.vpc_ip_range]
}
}
# -----------------------------------------------------------------------------
# Forgejo Firewall
# Allows HTTP, HTTPS, and Git SSH from anywhere
# System SSH only from VPC (handled by VPC rule)
# -----------------------------------------------------------------------------
resource "digitalocean_firewall" "forgejo" {
name = "${var.project_name}-${var.environment}-forgejo-fw"
droplet_ids = [var.forgejo_droplet_id]
# Inbound: HTTP
inbound_rule {
protocol = "tcp"
port_range = "80"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# Inbound: HTTPS
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# Inbound: Git SSH
inbound_rule {
protocol = "tcp"
port_range = var.forgejo_git_ssh_port
source_addresses = ["0.0.0.0/0", "::/0"]
}
# Inbound: Allow all VPC traffic (includes system SSH on non-standard port)
inbound_rule {
protocol = "tcp"
port_range = "1-65535"
source_addresses = [var.vpc_ip_range]
}
inbound_rule {
protocol = "udp"
port_range = "1-65535"
source_addresses = [var.vpc_ip_range]
}
inbound_rule {
protocol = "icmp"
source_addresses = [var.vpc_ip_range]
}
# Outbound: Only necessary traffic (security hardening)
outbound_rule {
protocol = "tcp"
port_range = "53"
destination_addresses = ["0.0.0.0/0", "::/0"] # DNS
}
outbound_rule {
protocol = "udp"
port_range = "53"
destination_addresses = ["0.0.0.0/0", "::/0"] # DNS
}
outbound_rule {
protocol = "tcp"
port_range = "80"
destination_addresses = ["0.0.0.0/0", "::/0"] # HTTP (apt, Let's Encrypt)
}
outbound_rule {
protocol = "tcp"
port_range = "443"
destination_addresses = ["0.0.0.0/0", "::/0"] # HTTPS (Docker, webhooks)
}
outbound_rule {
protocol = "udp"
port_range = "123"
destination_addresses = ["0.0.0.0/0", "::/0"] # NTP
}
outbound_rule {
protocol = "icmp"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
# VPC outbound (all ports for internal communication)
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = [var.vpc_ip_range]
}
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = [var.vpc_ip_range]
}
}

View file

@ -0,0 +1,13 @@
# =============================================================================
# Firewalls Module Outputs
# =============================================================================
output "jump_host_firewall_id" {
description = "ID of the jump host firewall"
value = digitalocean_firewall.jump_host.id
}
output "forgejo_firewall_id" {
description = "ID of the Forgejo firewall"
value = digitalocean_firewall.forgejo.id
}

View file

@ -0,0 +1,39 @@
# =============================================================================
# Firewalls Module Variables
# =============================================================================
variable "project_name" {
description = "Name of the project"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "vpc_ip_range" {
description = "VPC IP range for internal traffic"
type = string
}
variable "jump_host_droplet_id" {
description = "ID of the jump host droplet"
type = number
}
variable "jump_host_ssh_port" {
description = "SSH port for jump host"
type = number
}
variable "forgejo_droplet_id" {
description = "ID of the Forgejo droplet"
type = number
}
variable "forgejo_git_ssh_port" {
description = "Git SSH port for Forgejo"
type = number
default = 2222
}

View file

@ -0,0 +1,8 @@
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = ">= 2.34"
}
}
}

View file

@ -0,0 +1,590 @@
#cloud-config
# =============================================================================
# Forgejo Server Cloud-Init Configuration
# Git server with Docker for Forgejo 11.0.8 LTS - FULLY AUTOMATED
# =============================================================================
# NOTE: We install packages via runcmd instead of packages: section
# because DigitalOcean's base image cloud-init can interfere with the packages module
# bootcmd runs before write_files - create directories needed for write_files
bootcmd:
- mkdir -p /opt/forgejo/caddy
write_files:
# SSH daemon configuration (non-standard port, VPC only access expected)
- path: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
# Non-standard port (VPC access only via jump host)
Port ${ssh_port}
# Authentication hardening
PermitRootLogin prohibit-password
PasswordAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
# Security settings
MaxAuthTries 3
LoginGraceTime 30
PermitEmptyPasswords no
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
PermitUserEnvironment no
MaxSessions 3
# Session settings
ClientAliveInterval 300
ClientAliveCountMax 2
# Logging
LogLevel VERBOSE
permissions: '0644'
# NOTE: authorized_keys is managed by DigitalOcean's native SSH key provisioning
# The internal key is added via digitalocean_ssh_key.internal resource
# Fail2ban configuration for system SSH
- path: /etc/fail2ban/jail.d/sshd.local
content: |
[sshd]
enabled = true
port = ${ssh_port}
filter = sshd
logpath = /var/log/auth.log
backend = systemd
bantime = 1h
findtime = 10m
maxretry = 3
permissions: '0644'
# Fail2ban for Forgejo Git SSH
- path: /etc/fail2ban/jail.d/forgejo-ssh.local
content: |
[forgejo-ssh]
enabled = true
port = ${git_ssh_port}
filter = sshd
logpath = /var/lib/docker/containers/*forgejo*/*-json.log
backend = auto
bantime = 1h
findtime = 10m
maxretry = 5
permissions: '0644'
# Fail2ban filter for Forgejo HTTP auth failures
- path: /etc/fail2ban/filter.d/forgejo.conf
content: |
[Definition]
failregex = ^.*Failed authentication attempt for .* from <HOST>.*$
^.*invalid credentials from <HOST>.*$
ignoreregex =
permissions: '0644'
# Fail2ban jail for Forgejo HTTP
- path: /etc/fail2ban/jail.d/forgejo-http.local
content: |
[forgejo-http]
enabled = true
port = 80,443
filter = forgejo
logpath = /var/lib/docker/containers/*forgejo*/*-json.log
backend = auto
bantime = 1h
findtime = 10m
maxretry = 5
permissions: '0644'
# Docker daemon configuration (hardened)
- path: /etc/docker/daemon.json
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2",
"live-restore": true,
"no-new-privileges": true
}
permissions: '0644'
# UFW configuration script
- path: /usr/local/bin/configure-firewall.sh
content: |
#!/bin/bash
set -e
# Reset UFW
ufw --force reset
# Default policies
ufw default deny incoming
ufw default deny outgoing
# Inbound: HTTP/HTTPS for Forgejo web
ufw allow in 80/tcp comment 'HTTP'
ufw allow in 443/tcp comment 'HTTPS'
# Inbound: Git SSH (public)
ufw allow in ${git_ssh_port}/tcp comment 'Git SSH'
# Inbound: VPC traffic (includes system SSH on non-standard port)
ufw allow in from ${vpc_ip_range} comment 'VPC internal'
# Outbound: Only necessary traffic (matching DO firewall)
ufw allow out 53/tcp comment 'DNS'
ufw allow out 53/udp comment 'DNS'
ufw allow out 80/tcp comment 'HTTP'
ufw allow out 443/tcp comment 'HTTPS'
ufw allow out 123/udp comment 'NTP'
ufw allow out to ${vpc_ip_range} comment 'VPC'
# Enable logging for security audit
ufw logging high
# Enable UFW
ufw --force enable
echo "Firewall configured successfully"
permissions: '0755'
# Volume mount script
- path: /usr/local/bin/mount-volume.sh
content: |
#!/bin/bash
set -e
VOLUME_NAME="${volume_name}"
MOUNT_POINT="/mnt/forgejo"
# Create mount point
mkdir -p "$MOUNT_POINT"
# Find the volume device
# DigitalOcean volumes are typically at /dev/disk/by-id/scsi-0DO_Volume_*
VOLUME_DEV=$(readlink -f /dev/disk/by-id/scsi-0DO_Volume_$VOLUME_NAME 2>/dev/null || true)
if [ -z "$VOLUME_DEV" ]; then
echo "Waiting for volume to attach..."
sleep 10
VOLUME_DEV=$(readlink -f /dev/disk/by-id/scsi-0DO_Volume_$VOLUME_NAME 2>/dev/null || true)
fi
if [ -n "$VOLUME_DEV" ]; then
# Check if already mounted
if ! mountpoint -q "$MOUNT_POINT"; then
mount "$VOLUME_DEV" "$MOUNT_POINT"
echo "Volume mounted at $MOUNT_POINT"
fi
# Add to fstab if not already there
if ! grep -q "$VOLUME_DEV" /etc/fstab; then
echo "$VOLUME_DEV $MOUNT_POINT ext4 defaults,nofail,discard 0 2" >> /etc/fstab
fi
# Create subdirectories
mkdir -p "$MOUNT_POINT/forgejo-data"
mkdir -p "$MOUNT_POINT/forgejo-db"
mkdir -p "$MOUNT_POINT/runner-data"
# Set permissions (UID 1000 is typically the forgejo user in container)
chown -R 1000:1000 "$MOUNT_POINT/forgejo-data"
chown -R 999:999 "$MOUNT_POINT/forgejo-db" # postgres user
chown -R 1000:1000 "$MOUNT_POINT/runner-data"
else
echo "WARNING: Volume not found. Please attach volume manually."
fi
permissions: '0755'
# Swap configuration script (needed for 1GB RAM)
- path: /usr/local/bin/configure-swap.sh
content: |
#!/bin/bash
set -e
SWAP_FILE="/swapfile"
SWAP_SIZE="2G"
if [ ! -f "$SWAP_FILE" ]; then
echo "Creating $SWAP_SIZE swap file..."
fallocate -l $SWAP_SIZE $SWAP_FILE
chmod 600 $SWAP_FILE
mkswap $SWAP_FILE
swapon $SWAP_FILE
# Add to fstab
echo "$SWAP_FILE none swap sw 0 0" >> /etc/fstab
# Adjust swappiness for better performance
echo "vm.swappiness=10" >> /etc/sysctl.conf
sysctl vm.swappiness=10
echo "Swap configured successfully"
else
echo "Swap file already exists"
fi
permissions: '0755'
# Unattended upgrades configuration
- path: /etc/apt/apt.conf.d/50unattended-upgrades
content: |
Unattended-Upgrade::Allowed-Origins {
"$${distro_id}:$${distro_codename}";
"$${distro_id}:$${distro_codename}-security";
"$${distro_id}:$${distro_codename}-updates";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
permissions: '0644'
# Auto-upgrades configuration
- path: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
permissions: '0644'
# ==========================================================================
# FORGEJO DOCKER COMPOSE STACK
# ==========================================================================
# Docker Compose file
- path: /opt/forgejo/docker-compose.yml
content: |
services:
# PostgreSQL Database
forgejo-db:
image: postgres:16-alpine
container_name: forgejo-db
restart: unless-stopped
environment:
POSTGRES_USER: $${POSTGRES_USER:-forgejo}
POSTGRES_PASSWORD: $${POSTGRES_PASSWORD}
POSTGRES_DB: $${POSTGRES_DB:-forgejo}
volumes:
- /mnt/forgejo/forgejo-db:/var/lib/postgresql/data
networks:
- forgejo-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-forgejo} -d $${POSTGRES_DB:-forgejo}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 128M
# Forgejo Git Server
forgejo:
image: codeberg.org/forgejo/forgejo:11.0.8-rootless
container_name: forgejo
restart: unless-stopped
depends_on:
forgejo-db:
condition: service_healthy
environment:
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: forgejo-db:5432
FORGEJO__database__NAME: $${POSTGRES_DB:-forgejo}
FORGEJO__database__USER: $${POSTGRES_USER:-forgejo}
FORGEJO__database__PASSWD: $${POSTGRES_PASSWORD}
FORGEJO__server__DOMAIN: $${FORGEJO_DOMAIN}
FORGEJO__server__ROOT_URL: https://$${FORGEJO_DOMAIN}/
FORGEJO__server__SSH_DOMAIN: $${FORGEJO_DOMAIN}
FORGEJO__server__SSH_PORT: ${git_ssh_port}
FORGEJO__server__SSH_LISTEN_PORT: ${git_ssh_port}
FORGEJO__server__START_SSH_SERVER: "true"
FORGEJO__server__HTTP_PORT: 3000
FORGEJO__server__LFS_START_SERVER: "true"
FORGEJO__security__INSTALL_LOCK: "true"
FORGEJO__security__SECRET_KEY: $${FORGEJO_SECRET_KEY}
FORGEJO__security__INTERNAL_TOKEN: $${FORGEJO_INTERNAL_TOKEN}
FORGEJO__security__PASSWORD_COMPLEXITY: "lower,upper,digit"
FORGEJO__security__MIN_PASSWORD_LENGTH: "12"
FORGEJO__oauth2__JWT_SECRET: $${FORGEJO_JWT_SECRET}
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
FORGEJO__service__ENABLE_NOTIFY_MAIL: "false"
FORGEJO__actions__ENABLED: "true"
FORGEJO__actions__DEFAULT_ACTIONS_URL: "https://code.forgejo.org"
FORGEJO__repository__DEFAULT_BRANCH: "main"
FORGEJO__repository__ENABLE_PUSH_CREATE_USER: "true"
FORGEJO__repository__ENABLE_PUSH_CREATE_ORG: "true"
FORGEJO__lfs__PATH: /data/lfs
FORGEJO__webhook__ALLOWED_HOST_LIST: "private"
FORGEJO__webhook__SKIP_TLS_VERIFY: "false"
FORGEJO__log__MODE: "console"
FORGEJO__log__LEVEL: "Info"
volumes:
- /mnt/forgejo/forgejo-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- forgejo-internal
- forgejo-public
ports:
- "${git_ssh_port}:${git_ssh_port}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 384M
reservations:
memory: 192M
# Caddy Reverse Proxy with auto-SSL and rate limiting
caddy:
build:
context: ./caddy
dockerfile: Dockerfile
container_name: forgejo-caddy
restart: unless-stopped
depends_on:
forgejo:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- /opt/forgejo/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- forgejo-public
environment:
FORGEJO_DOMAIN: $${FORGEJO_DOMAIN}
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
memory: 64M
reservations:
memory: 32M
networks:
forgejo-internal:
driver: bridge
internal: true
forgejo-public:
driver: bridge
volumes:
caddy_data:
caddy_config:
permissions: '0644'
# Caddy Dockerfile with rate-limit plugin
- path: /opt/forgejo/caddy/Dockerfile
content: |
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/mholt/caddy-ratelimit
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
permissions: '0644'
# Caddyfile for reverse proxy with rate limiting
- path: /opt/forgejo/Caddyfile
content: |
{
order rate_limit before basicauth
}
${domain} {
# Rate limiting - 100 requests per minute per IP
rate_limit {
zone forgejo_zone {
key {remote_host}
events 100
window 1m
}
}
reverse_proxy forgejo:3000
encode gzip zstd
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
log {
output file /data/access.log {
roll_size 10mb
roll_keep 5
}
}
}
permissions: '0644'
# Script to create .env with Terraform-generated secrets
- path: /usr/local/bin/setup-forgejo.sh
content: |
#!/bin/bash
set -e
ENV_FILE="/opt/forgejo/.env"
# Only create if .env doesn't exist (preserves on re-run)
if [ ! -f "$ENV_FILE" ]; then
echo "Writing Forgejo configuration..."
{
echo "# Forgejo configuration (secrets from Terraform)"
echo "FORGEJO_DOMAIN=${domain}"
echo ""
echo "# PostgreSQL"
echo "POSTGRES_USER=forgejo"
echo "POSTGRES_PASSWORD=${postgres_password}"
echo "POSTGRES_DB=forgejo"
echo ""
echo "# Forgejo Security Keys"
echo "FORGEJO_SECRET_KEY=${forgejo_secret_key}"
echo "FORGEJO_INTERNAL_TOKEN=${forgejo_internal_token}"
echo "FORGEJO_JWT_SECRET=${forgejo_jwt_secret}"
} > "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo "Configuration saved to $ENV_FILE"
else
echo ".env already exists, skipping"
fi
permissions: '0755'
# DNS wait script - waits for DNS to propagate before starting Caddy
- path: /usr/local/bin/wait-for-dns.sh
content: |
#!/bin/bash
DOMAIN="${domain}"
MAX_ATTEMPTS=60
ATTEMPT=0
echo "Waiting for DNS to propagate for $DOMAIN..."
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
if host "$DOMAIN" > /dev/null 2>&1; then
echo "DNS resolved for $DOMAIN"
exit 0
fi
ATTEMPT=$((ATTEMPT + 1))
echo "Attempt $ATTEMPT/$MAX_ATTEMPTS - waiting 10s..."
sleep 10
done
echo "WARNING: DNS did not resolve after $MAX_ATTEMPTS attempts"
echo "Let's Encrypt certificate may fail - run 'docker compose restart caddy' after DNS propagates"
exit 0
permissions: '0755'
# Message of the day
- path: /etc/motd
content: |
+---------------------------------------------------------------+
| FORGEJO GIT SERVER |
| |
| Domain: ${domain}
| Git SSH Port: ${git_ssh_port}
| |
| Data location: /mnt/forgejo |
| Docker compose: /opt/forgejo |
| |
| Commands: |
| cd /opt/forgejo && docker compose logs -f |
| cd /opt/forgejo && docker compose restart |
| |
+---------------------------------------------------------------+
permissions: '0644'
runcmd:
# Ensure .ssh directory exists with correct permissions
# (authorized_keys is managed by DigitalOcean's native SSH key provisioning)
- mkdir -p /root/.ssh && chmod 700 /root/.ssh
# Ensure sshd_config includes the .d directory (Debian 12 fix)
- grep -q 'Include /etc/ssh/sshd_config.d' /etc/ssh/sshd_config || sed -i '1i Include /etc/ssh/sshd_config.d/*.conf' /etc/ssh/sshd_config
# Comment out Port in main sshd_config so sshd_config.d takes precedence (Debian 12 fix)
- sed -i 's/^Port /#Port /' /etc/ssh/sshd_config
# Restart SSH with new configuration
- systemctl restart sshd
# Wait for any background apt processes to finish (DO images run apt on boot)
- while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do echo "Waiting for dpkg lock..."; sleep 5; done
# Install prerequisites
- apt-get update
- DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=60 install -y ca-certificates curl gnupg fail2ban ufw unattended-upgrades apt-listchanges vim git
# Add Docker's official GPG key and repository
- 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 $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
# Install Docker from official repository
- apt-get update
- DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=60 install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Enable and start Docker
- systemctl enable docker
- systemctl start docker
# Enable and start fail2ban
- systemctl enable fail2ban
- systemctl restart fail2ban
# Configure swap (important for 1GB RAM)
- /usr/local/bin/configure-swap.sh
# Mount the volume
- /usr/local/bin/mount-volume.sh
# Fix ownership for Forgejo container (runs as UID 1000)
# This must run AFTER all directories are created to ensure correct permissions
- chown -R 1000:1000 /mnt/forgejo/forgejo-data
# Configure firewall
- /usr/local/bin/configure-firewall.sh
# Generate secrets and create .env
- /usr/local/bin/setup-forgejo.sh
# Wait for DNS to propagate before starting Caddy (for Let's Encrypt)
- /usr/local/bin/wait-for-dns.sh
# Start Forgejo stack (build Caddy with rate-limit plugin, pull others)
- cd /opt/forgejo && docker compose build caddy
- cd /opt/forgejo && docker compose pull forgejo forgejo-db
- cd /opt/forgejo && docker compose up -d
# Enable unattended upgrades
- systemctl enable unattended-upgrades
- systemctl start unattended-upgrades
final_message: "Forgejo server fully deployed after $UPTIME seconds. Visit https://${domain}"

View file

@ -0,0 +1,97 @@
# =============================================================================
# Secret Generation
# =============================================================================
resource "random_password" "postgres" {
length = 32
special = false
}
resource "random_password" "forgejo_secret_key" {
length = 64
special = false
}
resource "random_password" "forgejo_internal_token" {
length = 64
special = false
}
resource "random_password" "forgejo_jwt_secret" {
length = 64
special = false
}
# =============================================================================
# Forgejo Volume (Block Storage)
# =============================================================================
resource "digitalocean_volume" "forgejo" {
name = "${var.project_name}-forgejo-${var.environment}"
region = var.region
size = var.volume_size
initial_filesystem_type = "ext4"
description = "Forgejo data volume for ${var.project_name}"
tags = var.tags
}
# =============================================================================
# Forgejo Droplet
# =============================================================================
resource "digitalocean_droplet" "forgejo" {
name = "${var.project_name}-forgejo-${var.environment}"
size = var.droplet_size
image = var.droplet_image
region = var.region
vpc_uuid = var.vpc_uuid
ssh_keys = var.ssh_keys
backups = var.enable_backups
monitoring = true
ipv6 = true
# Pass cloud-config directly without cloudinit_config wrapper
# (cloudinit_config MIME multipart format was being ignored by DigitalOcean)
user_data = templatefile("${path.module}/cloud-init.yaml.tpl", {
ssh_port = var.ssh_port
git_ssh_port = var.git_ssh_port
vpc_ip_range = var.vpc_ip_range
volume_name = "${var.project_name}-forgejo-${var.environment}"
domain = var.domain
postgres_password = random_password.postgres.result
forgejo_secret_key = random_password.forgejo_secret_key.result
forgejo_internal_token = random_password.forgejo_internal_token.result
forgejo_jwt_secret = random_password.forgejo_jwt_secret.result
})
tags = var.tags
lifecycle {
create_before_destroy = false
ignore_changes = [user_data]
}
}
# =============================================================================
# Volume Attachment
# =============================================================================
resource "digitalocean_volume_attachment" "forgejo" {
droplet_id = digitalocean_droplet.forgejo.id
volume_id = digitalocean_volume.forgejo.id
}
# =============================================================================
# DNS Record (optional - requires domain to be managed by DigitalOcean)
# =============================================================================
resource "digitalocean_record" "forgejo" {
count = var.manage_dns ? 1 : 0
domain = var.dns_zone
type = "A"
name = var.dns_record_name
value = digitalocean_droplet.forgejo.ipv4_address
ttl = 600
}

View file

@ -0,0 +1,34 @@
output "droplet_id" {
description = "ID of the Forgejo droplet"
value = digitalocean_droplet.forgejo.id
}
output "public_ip" {
description = "Public IPv4 address of the Forgejo droplet"
value = digitalocean_droplet.forgejo.ipv4_address
}
output "private_ip" {
description = "Private IPv4 address of the Forgejo droplet (VPC)"
value = digitalocean_droplet.forgejo.ipv4_address_private
}
output "urn" {
description = "URN of the Forgejo droplet"
value = digitalocean_droplet.forgejo.urn
}
output "volume_id" {
description = "ID of the Forgejo volume"
value = digitalocean_volume.forgejo.id
}
output "volume_name" {
description = "Name of the Forgejo volume"
value = digitalocean_volume.forgejo.name
}
output "dns_record_fqdn" {
description = "FQDN of the DNS record (if managed)"
value = var.manage_dns ? digitalocean_record.forgejo[0].fqdn : null
}

View file

@ -0,0 +1,99 @@
variable "project_name" {
description = "Project name"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "region" {
description = "DigitalOcean region"
type = string
}
variable "vpc_uuid" {
description = "UUID of the VPC"
type = string
}
variable "vpc_ip_range" {
description = "IP range of the VPC (CIDR notation)"
type = string
}
variable "ssh_keys" {
description = "List of SSH key IDs to add to the droplet"
type = list(string)
}
variable "droplet_size" {
description = "Size slug for the droplet"
type = string
default = "s-1vcpu-1gb-intel"
}
variable "droplet_image" {
description = "Image slug for the droplet"
type = string
default = "debian-12-x64"
}
variable "volume_size" {
description = "Size of the data volume in GB"
type = number
default = 50
}
variable "ssh_port" {
description = "System SSH port (non-standard, VPC only)"
type = number
default = 52913
}
variable "git_ssh_port" {
description = "Git SSH port (public)"
type = number
default = 2222
}
variable "domain" {
description = "Domain name for Forgejo"
type = string
default = "qbit.realms.pub"
}
variable "enable_backups" {
description = "Enable automated backups"
type = bool
default = true
}
variable "tags" {
description = "Tags to apply to resources"
type = list(string)
default = []
}
# =============================================================================
# DNS Configuration (optional)
# =============================================================================
variable "manage_dns" {
description = "Whether to manage DNS record via DigitalOcean"
type = bool
default = false
}
variable "dns_zone" {
description = "DNS zone (base domain) managed by DigitalOcean (e.g., realms.pub)"
type = string
default = ""
}
variable "dns_record_name" {
description = "DNS record name (subdomain, e.g., 'qbit' for qbit.realms.pub)"
type = string
default = ""
}

View file

@ -0,0 +1,12 @@
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = ">= 2.34"
}
cloudinit = {
source = "hashicorp/cloudinit"
version = ">= 2.3"
}
}
}

View file

@ -0,0 +1,175 @@
#cloud-config
# =============================================================================
# Jump Host (Bastion) Cloud-Init Configuration
# Minimal attack surface - SSH only, no Docker
# =============================================================================
# NOTE: We install packages via runcmd instead of packages: section
# because DigitalOcean's base image cloud-init can interfere with the packages module
# SSH hardening configuration
write_files:
# SSH daemon configuration
- path: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
# Non-standard port
Port ${ssh_port}
# Authentication hardening
PermitRootLogin prohibit-password
PasswordAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
# Security settings
MaxAuthTries 3
LoginGraceTime 30
PermitEmptyPasswords no
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
PermitUserEnvironment no
MaxSessions 3
# Session settings
ClientAliveInterval 300
ClientAliveCountMax 2
# Logging
LogLevel VERBOSE
permissions: '0644'
# Fail2ban SSH jail configuration
- path: /etc/fail2ban/jail.d/sshd.local
content: |
[sshd]
enabled = true
port = ${ssh_port}
filter = sshd
logpath = /var/log/auth.log
backend = systemd
bantime = 1h
findtime = 10m
maxretry = 3
permissions: '0644'
# UFW configuration script
- path: /usr/local/bin/configure-firewall.sh
content: |
#!/bin/bash
set -e
# Reset UFW
ufw --force reset
# Default policies
ufw default deny incoming
ufw default deny outgoing
# Inbound: SSH on non-standard port
ufw allow in ${ssh_port}/tcp comment 'SSH'
# Inbound: VPC traffic
ufw allow in from ${vpc_ip_range} comment 'VPC internal'
# Outbound: Only necessary traffic (matching DO firewall)
ufw allow out 53/tcp comment 'DNS'
ufw allow out 53/udp comment 'DNS'
ufw allow out 80/tcp comment 'HTTP'
ufw allow out 443/tcp comment 'HTTPS'
ufw allow out 123/udp comment 'NTP'
ufw allow out to ${vpc_ip_range} comment 'VPC'
# Enable logging for security audit
ufw logging high
# Enable UFW
ufw --force enable
echo "Firewall configured successfully"
permissions: '0755'
# Unattended upgrades configuration
- path: /etc/apt/apt.conf.d/50unattended-upgrades
content: |
Unattended-Upgrade::Allowed-Origins {
"$${distro_id}:$${distro_codename}";
"$${distro_id}:$${distro_codename}-security";
"$${distro_id}:$${distro_codename}-updates";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
permissions: '0644'
# Auto-upgrades configuration
- path: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
permissions: '0644'
# Message of the day
- path: /etc/motd
content: |
╔═══════════════════════════════════════════════════════════════╗
║ JUMP HOST / BASTION ║
║ ║
║ This is a restricted access server. ║
║ All connections are logged and monitored. ║
║ ║
║ Use this host to access internal infrastructure: ║
║ ssh -p 52913 root@10.10.0.x ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
permissions: '0644'
# Internal SSH private key (for accessing Forgejo and other internal servers)
- path: /root/.ssh/id_ed25519
content: |
${indent(6, internal_private_key)}
permissions: '0600'
# SSH config for internal servers
- path: /root/.ssh/config
content: |
Host 10.10.0.*
StrictHostKeyChecking accept-new
IdentityFile ~/.ssh/id_ed25519
User root
permissions: '0600'
runcmd:
# Ensure sshd_config includes the .d directory (Debian 12 fix)
- grep -q 'Include /etc/ssh/sshd_config.d' /etc/ssh/sshd_config || sed -i '1i Include /etc/ssh/sshd_config.d/*.conf' /etc/ssh/sshd_config
# Comment out Port in main sshd_config so sshd_config.d takes precedence (Debian 12 fix)
- sed -i 's/^Port /#Port /' /etc/ssh/sshd_config
# Restart SSH with new configuration
- systemctl restart sshd
# Wait for any background apt processes to finish (DO images run apt on boot)
- while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do echo "Waiting for dpkg lock..."; sleep 5; done
# Install all required packages (more reliable than packages: section on DO images)
- apt-get update
- DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=60 install -y fail2ban ufw unattended-upgrades apt-listchanges curl vim
# Enable and start fail2ban
- systemctl enable fail2ban
- systemctl restart fail2ban
# Configure firewall
- /usr/local/bin/configure-firewall.sh
# Enable unattended upgrades
- systemctl enable unattended-upgrades
- systemctl start unattended-upgrades
final_message: "Jump host initialization complete after $UPTIME seconds"

View file

@ -0,0 +1,39 @@
# =============================================================================
# Jump Host (Bastion) Droplet
# =============================================================================
data "cloudinit_config" "jump_host" {
gzip = false
base64_encode = false
part {
content_type = "text/cloud-config"
content = templatefile("${path.module}/cloud-init.yaml.tpl", {
ssh_port = var.ssh_port
vpc_ip_range = var.vpc_ip_range
internal_private_key = var.internal_private_key
})
}
}
resource "digitalocean_droplet" "jump_host" {
name = "${var.project_name}-jump-${var.environment}"
size = var.droplet_size
image = var.droplet_image
region = var.region
vpc_uuid = var.vpc_uuid
ssh_keys = var.ssh_keys
backups = var.enable_backups
monitoring = true
ipv6 = true
user_data = data.cloudinit_config.jump_host.rendered
tags = var.tags
lifecycle {
create_before_destroy = false
ignore_changes = [user_data] # Don't recreate on cloud-init changes
}
}

View file

@ -0,0 +1,19 @@
output "droplet_id" {
description = "ID of the jump host droplet"
value = digitalocean_droplet.jump_host.id
}
output "public_ip" {
description = "Public IPv4 address of the jump host"
value = digitalocean_droplet.jump_host.ipv4_address
}
output "private_ip" {
description = "Private IPv4 address of the jump host (VPC)"
value = digitalocean_droplet.jump_host.ipv4_address_private
}
output "urn" {
description = "URN of the jump host droplet"
value = digitalocean_droplet.jump_host.urn
}

View file

@ -0,0 +1,65 @@
variable "project_name" {
description = "Project name"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "region" {
description = "DigitalOcean region"
type = string
}
variable "vpc_uuid" {
description = "UUID of the VPC"
type = string
}
variable "vpc_ip_range" {
description = "IP range of the VPC (CIDR notation)"
type = string
}
variable "ssh_keys" {
description = "List of SSH key IDs to add to the droplet"
type = list(string)
}
variable "droplet_size" {
description = "Size slug for the droplet"
type = string
default = "s-1vcpu-512mb-10gb"
}
variable "droplet_image" {
description = "Image slug for the droplet"
type = string
default = "debian-12-x64"
}
variable "ssh_port" {
description = "SSH port (non-standard for security)"
type = number
default = 49822
}
variable "enable_backups" {
description = "Enable automated backups"
type = bool
default = true
}
variable "tags" {
description = "Tags to apply to the droplet"
type = list(string)
default = []
}
variable "internal_private_key" {
description = "Private SSH key for accessing internal servers (Forgejo, etc.)"
type = string
sensitive = true
}

View file

@ -0,0 +1,12 @@
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = ">= 2.34"
}
cloudinit = {
source = "hashicorp/cloudinit"
version = ">= 2.3"
}
}
}

View file

@ -0,0 +1,46 @@
# =============================================================================
# Admin SSH Keys (provided by user)
# =============================================================================
resource "digitalocean_ssh_key" "admin" {
for_each = var.admin_ssh_public_keys
name = "${var.project_name}-${var.environment}-${each.key}"
public_key = each.value
}
# =============================================================================
# Deploy Key (auto-generated for Forgejo Actions CI/CD)
# =============================================================================
resource "tls_private_key" "deploy" {
algorithm = "ED25519"
}
resource "digitalocean_ssh_key" "deploy" {
name = "${var.project_name}-${var.environment}-deploy-key"
public_key = tls_private_key.deploy.public_key_openssh
}
# =============================================================================
# Save deploy key locally for initial setup (optional)
# =============================================================================
resource "local_sensitive_file" "deploy_private_key" {
content = tls_private_key.deploy.private_key_openssh
filename = "${path.root}/.secrets/deploy_key_${var.environment}"
file_permission = "0600"
}
# =============================================================================
# Internal VPC Key (jump host internal servers like Forgejo)
# =============================================================================
resource "tls_private_key" "internal" {
algorithm = "ED25519"
}
resource "digitalocean_ssh_key" "internal" {
name = "${var.project_name}-${var.environment}-internal-key"
public_key = tls_private_key.internal.public_key_openssh
}

View file

@ -0,0 +1,54 @@
output "admin_ssh_key_ids" {
description = "IDs of admin SSH keys"
value = [for key in digitalocean_ssh_key.admin : key.id]
}
output "deploy_ssh_key_id" {
description = "ID of the deploy SSH key"
value = digitalocean_ssh_key.deploy.id
}
output "all_ssh_key_ids" {
description = "All SSH key IDs (admin + deploy)"
value = concat(
[for key in digitalocean_ssh_key.admin : key.id],
[digitalocean_ssh_key.deploy.id]
)
}
output "internal_ssh_key_id" {
description = "ID of the internal VPC SSH key"
value = digitalocean_ssh_key.internal.id
}
output "forgejo_ssh_key_ids" {
description = "SSH key IDs for Forgejo (internal only - access via jump host)"
value = [digitalocean_ssh_key.internal.id]
}
output "deploy_key_fingerprint" {
description = "Fingerprint of the deploy key"
value = digitalocean_ssh_key.deploy.fingerprint
}
output "deploy_private_key" {
description = "Private key for deployment (add to Forgejo secrets)"
value = tls_private_key.deploy.private_key_openssh
sensitive = true
}
output "deploy_public_key" {
description = "Public key for deployment"
value = tls_private_key.deploy.public_key_openssh
}
output "internal_private_key" {
description = "Private key for jump host → internal servers"
value = tls_private_key.internal.private_key_openssh
sensitive = true
}
output "internal_public_key" {
description = "Public key for internal server authentication"
value = tls_private_key.internal.public_key_openssh
}

View file

@ -0,0 +1,15 @@
variable "project_name" {
description = "Project name"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "admin_ssh_public_keys" {
description = "Map of admin SSH public keys (name => public_key)"
type = map(string)
default = {}
}

View file

@ -0,0 +1,12 @@
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = ">= 2.34"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
}
}

View file

@ -0,0 +1,10 @@
# =============================================================================
# VPC Resource
# =============================================================================
resource "digitalocean_vpc" "main" {
name = var.name
region = var.region
ip_range = var.ip_range
description = var.description
}

View file

@ -0,0 +1,14 @@
output "vpc_id" {
description = "ID of the VPC"
value = digitalocean_vpc.main.id
}
output "vpc_urn" {
description = "URN of the VPC"
value = digitalocean_vpc.main.urn
}
output "vpc_ip_range" {
description = "IP range of the VPC"
value = digitalocean_vpc.main.ip_range
}

View file

@ -0,0 +1,20 @@
variable "name" {
description = "Name of the VPC"
type = string
}
variable "region" {
description = "DigitalOcean region"
type = string
}
variable "ip_range" {
description = "IP range for the VPC (CIDR notation)"
type = string
}
variable "description" {
description = "Description of the VPC"
type = string
default = ""
}

View file

@ -0,0 +1,8 @@
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = ">= 2.34"
}
}
}