feat: add caddy proxy

This commit is contained in:
Yuris Cakranegara
2025-06-12 20:55:58 +10:00
parent d801b0b86d
commit af038e23ea
11 changed files with 488 additions and 0 deletions

View File

@@ -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"

22
main.tf
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}
```

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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 = ""
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}