feat: add caddy proxy
This commit is contained in:
@@ -3,6 +3,7 @@ TIMEZONE="Australia/Brisbane"
|
|||||||
DATA_DIR="/mnt/appdata"
|
DATA_DIR="/mnt/appdata"
|
||||||
PUID="1000"
|
PUID="1000"
|
||||||
PGID="1000"
|
PGID="1000"
|
||||||
|
EXTERNAL_IP=your-public-ip-address
|
||||||
|
|
||||||
# Cloudflare
|
# Cloudflare
|
||||||
CLOUDFLARE_API_TOKEN="your-cloudflare-api-token"
|
CLOUDFLARE_API_TOKEN="your-cloudflare-api-token"
|
||||||
|
|||||||
22
main.tf
22
main.tf
@@ -1,7 +1,15 @@
|
|||||||
|
module "system_globals" {
|
||||||
|
source = "./modules/00-globals/system"
|
||||||
|
}
|
||||||
|
|
||||||
module "cloudflare_globals" {
|
module "cloudflare_globals" {
|
||||||
source = "./modules/00-globals/cloudflare"
|
source = "./modules/00-globals/cloudflare"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module "tls_globals" {
|
||||||
|
source = "./modules/00-globals/tls"
|
||||||
|
}
|
||||||
|
|
||||||
module "watchtower" {
|
module "watchtower" {
|
||||||
source = "./modules/20-services-apps/watchtower"
|
source = "./modules/20-services-apps/watchtower"
|
||||||
}
|
}
|
||||||
@@ -22,3 +30,17 @@ module "homelab_cloudflared_tunnel" {
|
|||||||
networks = [module.services.homelab_docker_network_name]
|
networks = [module.services.homelab_docker_network_name]
|
||||||
monitoring = true
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,3 +30,8 @@ output "cloudflare_api_token" {
|
|||||||
value = data.dotenv_sensitive.cloudflare_credentials.entries.CLOUDFLARE_API_TOKEN
|
value = data.dotenv_sensitive.cloudflare_credentials.entries.CLOUDFLARE_API_TOKEN
|
||||||
sensitive = true
|
sensitive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output "external_ip" {
|
||||||
|
description = "External IP address for the homelab"
|
||||||
|
value = data.dotenv.cloudflare_config.entries.EXTERNAL_IP
|
||||||
|
}
|
||||||
|
|||||||
15
modules/00-globals/tls/main.tf
Normal file
15
modules/00-globals/tls/main.tf
Normal 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
|
||||||
|
}
|
||||||
147
modules/01-networking/caddy-proxy/README.md
Normal file
147
modules/01-networking/caddy-proxy/README.md
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
131
modules/01-networking/caddy-proxy/main.tf
Normal file
131
modules/01-networking/caddy-proxy/main.tf
Normal 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
|
||||||
|
}
|
||||||
16
modules/01-networking/caddy-proxy/outputs.tf
Normal file
16
modules/01-networking/caddy-proxy/outputs.tf
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
63
modules/01-networking/caddy-proxy/variables.tf
Normal file
63
modules/01-networking/caddy-proxy/variables.tf
Normal 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 = ""
|
||||||
|
}
|
||||||
33
modules/10-services-generic/cloudflare-dns/main.tf
Normal file
33
modules/10-services-generic/cloudflare-dns/main.tf
Normal 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
|
||||||
|
}
|
||||||
9
modules/10-services-generic/cloudflare-dns/outputs.tf
Normal file
9
modules/10-services-generic/cloudflare-dns/outputs.tf
Normal 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)
|
||||||
|
}
|
||||||
46
modules/10-services-generic/cloudflare-dns/variables.tf
Normal file
46
modules/10-services-generic/cloudflare-dns/variables.tf
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user