diff --git a/.env.example b/.env.example index 8cca7c5..5703aac 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ TIMEZONE="Australia/Brisbane" DATA_DIR="/mnt/appdata" PUID="1000" PGID="1000" +EXTERNAL_IP=your-public-ip-address # Cloudflare CLOUDFLARE_API_TOKEN="your-cloudflare-api-token" diff --git a/main.tf b/main.tf index 516cc78..7564a54 100644 --- a/main.tf +++ b/main.tf @@ -1,7 +1,15 @@ +module "system_globals" { + source = "./modules/00-globals/system" +} + module "cloudflare_globals" { source = "./modules/00-globals/cloudflare" } +module "tls_globals" { + source = "./modules/00-globals/tls" +} + module "watchtower" { source = "./modules/20-services-apps/watchtower" } @@ -22,3 +30,17 @@ module "homelab_cloudflared_tunnel" { networks = [module.services.homelab_docker_network_name] monitoring = true } + +module "homelab_caddy_proxy" { + source = "./modules/01-networking/caddy-proxy" + + domain = module.cloudflare_globals.domain + tls_email = module.tls_globals.tls_email + container_name = "caddy-proxy" + cloudflare_zone_id = module.cloudflare_globals.cloudflare_zone_id + external_ip = module.cloudflare_globals.external_ip + service_definitions = module.services.service_definitions + volume_path = module.system_globals.volume_host + networks = [module.services.homelab_docker_network_name] + monitoring = true +} diff --git a/modules/00-globals/cloudflare/main.tf b/modules/00-globals/cloudflare/main.tf index fd5d611..4b90b32 100644 --- a/modules/00-globals/cloudflare/main.tf +++ b/modules/00-globals/cloudflare/main.tf @@ -30,3 +30,8 @@ output "cloudflare_api_token" { value = data.dotenv_sensitive.cloudflare_credentials.entries.CLOUDFLARE_API_TOKEN sensitive = true } + +output "external_ip" { + description = "External IP address for the homelab" + value = data.dotenv.cloudflare_config.entries.EXTERNAL_IP +} diff --git a/modules/00-globals/tls/main.tf b/modules/00-globals/tls/main.tf new file mode 100644 index 0000000..55f0d77 --- /dev/null +++ b/modules/00-globals/tls/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + dotenv = { + source = "germanbrew/dotenv" + } + } +} + +data "dotenv" "system_config" {} + +// Outputs +output "tls_email" { + description = "TLS email" + value = data.dotenv.system_config.entries.TLS_EMAIL +} diff --git a/modules/01-networking/caddy-proxy/README.md b/modules/01-networking/caddy-proxy/README.md new file mode 100644 index 0000000..0c1e151 --- /dev/null +++ b/modules/01-networking/caddy-proxy/README.md @@ -0,0 +1,147 @@ +# Caddy Proxy Module + +This module creates a Caddy reverse proxy server that dynamically configures itself based on service definitions passed to it. + +## Overview + +The Caddy Proxy module: +- Accepts service definitions that specify whether to expose them via reverse proxy +- Dynamically generates Caddyfile configuration from these service definitions +- Supports custom Caddy configuration blocks per service +- Deploys a Caddy container with the generated configuration +- Manages TLS certificates automatically using Let's Encrypt +- Creates DNS records for services with configurable Cloudflare proxying settings + +## Usage + +### Basic Integration + +Add the module to your main Terraform configuration: + +```hcl +module "homelab_caddy_proxy" { + source = "./modules/01-networking/caddy-proxy" + domain = "yourdomain.com" + tls_email = "your-email@example.com" # For Let's Encrypt + container_name = "caddy-proxy" + cloudflare_zone_id = module.cloudflare_globals.cloudflare_zone_id + external_ip = module.cloudflare_globals.external_ip + service_definitions = module.services.service_definitions + networks = ["your-docker-network"] + monitoring = true +} +``` + +### Service Definition Format + +Services should include the following fields to be properly exposed through Caddy: + +```hcl +{ + name = "service-name" + endpoint = "service-container:port" + subdomains = ["app", "dashboard"] # Will create app.yourdomain.com, dashboard.yourdomain.com + + # Specify how to publish this service: "tunnel", "reverse_proxy", or "both" (default) + publish_via = "both" + + # Control whether the DNS record is proxied through Cloudflare (default: true) + proxied = true + + # Option 1: Simplified Caddy configuration via options + caddy_options = { + "health_path" = "/health" + "health_interval" = "30s" + "header_up X-Real-IP" = "{http.request.remote}" + # Additional reverse_proxy options as needed + } + + # Option 2: Full custom Caddy configuration (takes precedence if both are provided) + caddy_config = <<-EOT + # Raw Caddy configuration goes here + reverse_proxy /api/* api-backend:8080 + reverse_proxy /* frontend:3000 + header X-Powered-By "My Awesome Homelab" + log { + output file /var/log/access.log + } + EOT +} +``` + +The `publish_via` field controls which networking module(s) will expose the service: +- `"tunnel"`: Service will only be published via Cloudflare tunnel +- `"reverse_proxy"`: Service will only be exposed via Caddy reverse proxy +- `"both"`: Service will be published via both methods (default) + +## Variables + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `container_name` | The name of the Caddy container | `string` | `""` (generates "caddy-proxy") | +| `image_tag` | The tag of the Caddy Docker image to use | `string` | `"latest"` | +| `domain` | The domain name to use for services | `string` | - | +| `tls_email` | Email address for Let's Encrypt | `string` | - | +| `service_definitions` | List of service definitions to evaluate | `list(object)` | - | +| `networks` | List of Docker networks to connect to | `list(string)` | `[]` | +| `monitoring` | Whether to enable monitoring for the container | `bool` | `false` | +| `cloudflare_zone_id` | Cloudflare Zone ID for creating DNS records | `string` | `""` | +| `external_ip` | External IP address for A records | `string` | `""` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `container_name` | The name of the deployed Caddy container | +| `config_hash` | The SHA256 hash of the generated Caddyfile content | +| `service_sites` | Map of generated Caddy site configurations | + +## Example Service Integration + +### Basic Service with Default Settings + +```hcl +# Example based on ntfy (reverse-proxy only with direct IP exposure) +output "service_definition" { + description = "Service definition for a notification service" + value = { + name = "ntfy" + primary_port = 80 + endpoint = "http://ntfy:80" + subdomains = ["ntfy"] + publish_via = "reverse_proxy" # Only expose via Caddy reverse proxy + proxied = false # Don't proxy through Cloudflare (expose direct IP) + } +} +``` + +### Service with Custom Caddy Configuration + +```hcl +# Example showing a service with custom Caddy configuration +output "service_definition" { + description = "Service definition with custom Caddy configuration" + value = { + name = "custom-service" + primary_port = 8080 + endpoint = "http://custom-service:8080" + subdomains = ["custom"] + publish_via = "reverse_proxy" + proxied = true # Use Cloudflare proxying (default) + caddy_config = <<-EOT + # Handle API requests specially + handle /api/* { + reverse_proxy custom-service:8080 { + header_up X-Real-IP {remote} + } + } + + # Handle all other requests + handle { + reverse_proxy custom-service:8080 + header +Access-Control-Allow-Origin "*" + } + EOT + } +} +``` diff --git a/modules/01-networking/caddy-proxy/main.tf b/modules/01-networking/caddy-proxy/main.tf new file mode 100644 index 0000000..e271eaa --- /dev/null +++ b/modules/01-networking/caddy-proxy/main.tf @@ -0,0 +1,131 @@ +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + } + cloudflare = { + source = "cloudflare/cloudflare" + } + } +} + +locals { + container_name = var.container_name != "" ? var.container_name : "caddy-proxy" + image_tag = var.image_tag != "" ? var.image_tag : "latest" + + // Filter services to only include those that should be published via reverse proxy + proxy_services = [ + for service in var.service_definitions : + service if length(service.subdomains) > 0 && (service.publish_via == "reverse_proxy" || service.publish_via == "both") + ] + + // Transform service definitions into Caddyfile blocks + caddy_site_configs = flatten([ + for service in local.proxy_services : + [ + for subdomain in service.subdomains : { + site_address = "${subdomain}.${var.domain}" + endpoint = service.endpoint + service_name = service.name + tls_email = var.tls_email + has_custom_config = service.caddy_config != "" + custom_config = service.caddy_config + reverse_proxy_options = service.caddy_options + proxied = service.proxied + } + ] + ]) + + // Generate the main Caddyfile content + caddyfile_content = join("\n\n", [ + for site in local.caddy_site_configs : + site.has_custom_config ? + // Use the custom Caddy config if provided + <<-EOT + ${site.site_address} { + tls ${var.tls_email} + ${site.custom_config} + } + EOT + : + // Otherwise use the standard reverse proxy config with options + <<-EOT + ${site.site_address} { + tls ${var.tls_email} + reverse_proxy ${site.endpoint} { + ${join("\n ", [ + for key, value in site.reverse_proxy_options : + "${key} ${value}" +])} + } + } + EOT +]) +} + +resource "docker_volume" "caddy_config" { + name = "${local.container_name}_config" +} + +// Create Caddyfile in the volume path +resource "local_file" "caddyfile" { + content = local.caddyfile_content + filename = "${var.volume_path}/caddy/Caddyfile" +} + +module "dns_records" { + count = var.cloudflare_zone_id != "" ? 1 : 0 + source = "../../10-services-generic/cloudflare-dns" + zone_id = var.cloudflare_zone_id + dns_records = { + for site in local.caddy_site_configs : site.site_address => { + name = site.site_address + value = var.external_ip + type = "A" + proxied = site.proxied + ttl = 1 + } + } +} + +module "caddy" { + source = "../../10-services-generic/docker-service" + + container_name = local.container_name + image = "caddy" + tag = local.image_tag + + volumes = [ + { + host_path = "${var.volume_path}/caddy/data" + container_path = "/data" + read_only = false + }, + { + host_path = "${var.volume_path}/caddy/config" + container_path = "/config" + read_only = false + }, + { + host_path = "${var.volume_path}/caddy/Caddyfile" + container_path = "/etc/caddy/Caddyfile" + read_only = true + } + ] + + ports = [ + { + external = "9080" + internal = "80" + protocol = "tcp" + }, + { + external = "9443" + internal = "443" + protocol = "tcp" + } + ] + + monitoring = var.monitoring + networks = var.networks +} diff --git a/modules/01-networking/caddy-proxy/outputs.tf b/modules/01-networking/caddy-proxy/outputs.tf new file mode 100644 index 0000000..b4adb04 --- /dev/null +++ b/modules/01-networking/caddy-proxy/outputs.tf @@ -0,0 +1,16 @@ +output "container_name" { + description = "The name of the deployed Caddy container" + value = module.caddy.container_name +} + +output "config_hash" { + description = "The SHA256 hash of the generated Caddyfile content" + value = sha256(local.caddyfile_content) +} + +output "service_sites" { + description = "Map of generated Caddy site configurations" + value = { + for site in local.caddy_site_configs : site.site_address => site.endpoint + } +} diff --git a/modules/01-networking/caddy-proxy/variables.tf b/modules/01-networking/caddy-proxy/variables.tf new file mode 100644 index 0000000..a4403da --- /dev/null +++ b/modules/01-networking/caddy-proxy/variables.tf @@ -0,0 +1,63 @@ +variable "container_name" { + description = "The name of the Caddy container" + type = string + default = "" +} + +variable "image_tag" { + description = "The tag of the Caddy Docker image to use" + type = string + default = "latest" +} + +variable "volume_path" { + description = "Base directory for volumes" + type = string +} + +variable "domain" { + description = "The domain name to use for services" + type = string +} + +variable "tls_email" { + description = "Email address to use for TLS certificate generation with Let's Encrypt" + type = string +} + +variable "service_definitions" { + description = "List of service definitions to evaluate for exposure through Caddy" + type = list(object({ + name = string + endpoint = string + subdomains = optional(list(string), []) + publish_via = optional(string) + caddy_config = optional(string, "") + caddy_options = optional(map(string), {}) + proxied = optional(bool, true) # Controls whether the DNS record is proxied through Cloudflare + })) +} + +variable "networks" { + description = "List of Docker networks to connect the Caddy container to" + type = list(string) + default = [] +} + +variable "monitoring" { + description = "Whether to enable monitoring for the Caddy container" + type = bool + default = false +} + +variable "cloudflare_zone_id" { + description = "Cloudflare Zone ID for creating DNS records" + type = string + default = "" +} + +variable "external_ip" { + description = "External IP address for A records when using create_dns_records" + type = string + default = "" +} diff --git a/modules/10-services-generic/cloudflare-dns/main.tf b/modules/10-services-generic/cloudflare-dns/main.tf new file mode 100644 index 0000000..d3c9171 --- /dev/null +++ b/modules/10-services-generic/cloudflare-dns/main.tf @@ -0,0 +1,33 @@ +terraform { + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + } + } +} + +locals { + hostname_records = length(var.hostnames) > 0 ? { + for hostname in var.hostnames : + hostname => { + name = split(".", hostname)[0] // Extract subdomain + value = var.target_content + type = var.record_type + proxied = var.proxied + ttl = var.ttl + } + } : {} + + all_records = merge(local.hostname_records, var.dns_records) +} + +resource "cloudflare_record" "service" { + for_each = local.all_records + + zone_id = var.zone_id + name = each.value.name + content = each.value.value + type = each.value.type + proxied = each.value.proxied + ttl = each.value.ttl +} diff --git a/modules/10-services-generic/cloudflare-dns/outputs.tf b/modules/10-services-generic/cloudflare-dns/outputs.tf new file mode 100644 index 0000000..c6cec7d --- /dev/null +++ b/modules/10-services-generic/cloudflare-dns/outputs.tf @@ -0,0 +1,9 @@ +output "dns_records" { + description = "Map of DNS records created" + value = cloudflare_record.service +} + +output "record_hostnames" { + description = "List of hostnames for which DNS records were created" + value = keys(local.all_records) +} diff --git a/modules/10-services-generic/cloudflare-dns/variables.tf b/modules/10-services-generic/cloudflare-dns/variables.tf new file mode 100644 index 0000000..fc4e902 --- /dev/null +++ b/modules/10-services-generic/cloudflare-dns/variables.tf @@ -0,0 +1,46 @@ +variable "zone_id" { + description = "Cloudflare Zone ID" + type = string +} + +variable "dns_records" { + description = "Map of DNS records to create" + type = map(object({ + name = string + value = string + type = string + proxied = bool + ttl = number + })) + default = {} +} + +variable "hostnames" { + description = "List of hostnames to create DNS records for" + type = list(string) + default = [] +} + +variable "target_content" { + description = "Target content/value for the DNS records when using hostnames list" + type = string + default = "" +} + +variable "record_type" { + description = "Record type for the DNS records when using hostnames list" + type = string + default = "CNAME" +} + +variable "proxied" { + description = "Whether the records should be proxied through Cloudflare" + type = bool + default = true +} + +variable "ttl" { + description = "TTL for the records (only used when proxied=false)" + type = number + default = 1 # Auto +}