Start creating new homelab settings with OpenTofu

This commit is contained in:
2025-08-06 14:23:17 +00:00
commit 2fb1c45e87
20 changed files with 1132 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.terraform/
*.tfstate
*.tfstate.*
crash.log
*.tfvars
override.tf
override.tf.json
.terraformrc
terraform.rc
.env
+23
View File
@@ -0,0 +1,23 @@
module "system_globals" {
source = "./modules/00-globals/system"
}
// Application services
module "services" {
source = "./services"
}
module "caddy" {
source = "./modules/01-networking/caddy-service"
volume_path = "./docker/infrastructure/"
domains = [
"blackchaosnl.duckdns.org",
"blackchaosnl.myaddr.io",
"blackchaosnl.myaddr.dev",
"blackchaosnl.myaddr.tools"
]
tls_email = "your-email@example.com" # For Let's Encrypt
container_name = "caddy"
service_definitions = module.services.service_definitions
networks = ["default"]
}
+30
View File
@@ -0,0 +1,30 @@
terraform {
required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
data "dotenv" "system_config" {}
// Outputs
output "timezone" {
description = "System timezone"
value = data.dotenv.system_config.entries.TIMEZONE
}
output "volume_host" {
description = "Base directory for host volumes"
value = data.dotenv.system_config.entries.VOLUME_HOST
}
output "puid" {
description = "PUID for Docker containers"
value = data.dotenv.system_config.entries.PUID
}
output "pgid" {
description = "PGID for Docker containers"
value = data.dotenv.system_config.entries.PGID
}
+15
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
}
@@ -0,0 +1,126 @@
# 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"
domains = ["yourdomain.com"]
tls_email = "your-email@example.com" # For Let's Encrypt
container_name = "caddy-proxy"
service_definitions = module.services.service_definitions
networks = ["your-docker-network"]
}
```
### 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
# 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
}
```
## 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"` |
| `domains` | The domain names to use for services | `list(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)` | `[]` |
## 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"]
}
}
```
### 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"]
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
}
}
```
+141
View File
@@ -0,0 +1,141 @@
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
}
}
}
locals {
container_name = var.container_name != "" ? var.container_name : "caddy"
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
]
// Transform service definitions into Caddyfile blocks
caddy_site_configs = flatten([
for service in local.proxy_services :
[
for domain in var.domains : [
for subdomain in service.subdomains : {
site_address = "${subdomain}.${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
}
]
]
])
caddyfile_default = <<-EOT
{
email ${var.tls_email}
log {
format console
output stdout
}
}
(headers) {
header {
-server
-via
Permissions-Policy interest-cohort=()
Strict-Transport-Security "max-age=31536000; includesSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options DENY
}
}
EOT
// Generate the main Caddyfile content
caddyfile_content = merge(local.caddyfile_default, 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} {
import headers
${site.custom_config}
}
EOT
:
// Otherwise use the standard reverse proxy config with options
<<-EOT
${site.site_address} {
import headers
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 "caddy" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = "caddy"
tag = local.image_tag
volumes = [
{
host_path = "${var.volume_path}/${image}/data"
container_path = "/data"
read_only = false
},
{
host_path = "${var.volume_path}/${image}/config"
container_path = "/config"
read_only = false
},
{
host_path = "${var.volume_path}/${image}/Caddyfile"
container_path = "/etc/caddy/Caddyfile"
read_only = true
}
]
ports = [
{
external = "80"
internal = "80"
protocol = "tcp"
},
{
external = "443"
internal = "443"
protocol = "tcp"
}
]
networks = var.networks
}
@@ -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
}
}
@@ -0,0 +1,46 @@
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 "domains" {
description = "Which domain name to use for services"
type = list(object({
name = 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), {})
}))
}
variable "networks" {
description = "List of Docker networks to connect the Caddy container to"
type = list(string)
default = []
}
@@ -0,0 +1,27 @@
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
}
}
}
resource "docker_network" "this" {
name = var.name
driver = var.driver
internal = var.internal
attachable = var.attachable
ipam_driver = var.ipam_driver
dynamic "ipam_config" {
for_each = var.subnet != "" ? [1] : []
content {
subnet = var.subnet
gateway = var.gateway
ip_range = var.ip_range
aux_address = var.aux_address
}
}
options = var.options
}
@@ -0,0 +1,19 @@
output "network_id" {
description = "The ID of the Docker network"
value = docker_network.this.id
}
output "name" {
description = "The name of the Docker network"
value = docker_network.this.name
}
output "network_driver" {
description = "The driver of the Docker network"
value = docker_network.this.driver
}
output "ipam_config" {
description = "The IPAM configuration of the Docker network"
value = docker_network.this.ipam_config
}
@@ -0,0 +1,64 @@
variable "name" {
description = "Name of the Docker network"
type = string
}
variable "driver" {
description = "Name of the network driver to use"
type = string
default = "bridge"
}
variable "internal" {
description = "Restrict external access to the network if true"
type = bool
default = false
}
variable "attachable" {
description = "Enable manual container attachment if true"
type = bool
default = true
}
variable "ipam_driver" {
description = "Driver used for IP address management"
type = string
default = "default"
}
variable "subnet" {
description = "Subnet in CIDR format that represents a network segment"
type = string
default = ""
}
variable "gateway" {
description = "IPv4 or IPv6 gateway for the subnet"
type = string
default = ""
}
variable "ip_range" {
description = "Range of IPs from which to allocate container IPs"
type = string
default = ""
}
variable "aux_address" {
description = "Auxiliary IPv4 or IPv6 addresses used by the driver"
type = map(string)
default = {}
}
variable "labels" {
description = "Labels to add to the network"
type = map(string)
default = {}
}
variable "options" {
description = "Network driver specific options"
type = map(string)
default = {}
}
@@ -0,0 +1,88 @@
# Generic Docker Service Module
This is a reusable OpenTofu module for deploying Docker containers with configurable options. It serves as the foundation for specific application modules in this homelab project.
## Features
- Pull and manage Docker images
- Configure container networking, ports, and volumes
- Set environment variables and labels
- Configure resource limits and constraints
- Set up health checks
- Support for container logging options
## Usage
This module is typically called by application-specific modules rather than used directly, but can be used as follows:
```hcl
module "my_service" {
source = "../../10-services-generic/docker-service"
container_name = "my-service"
image = "organization/image"
tag = "latest"
restart_policy = "unless-stopped"
network_mode = "bridge"
// Port mappings
ports = [
{
internal = 8080
external = 8080
protocol = "tcp"
}
]
// Volume mappings
volumes = [
{
host_path = "/path/on/host"
container_path = "/path/in/container"
read_only = false
}
]
// Environment variables
env_vars = {
VARIABLE_NAME = "value"
}
// Container labels
labels = {
"com.example.description" = "My service description"
}
}
```
## Required Providers
This module requires the Docker provider to be configured in your root module:
```hcl
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
}
dotenv = {
source = "germanbrew/dotenv"
}
}
}
```
## Inputs
See the `variables.tf` file for a complete list of input variables and their descriptions.
## Outputs
| Name | Description |
| --------------- | ------------------------------------------- |
| container_name | Name of the Docker container |
| container_id | ID of the Docker container |
| image_id | ID of the Docker image |
| ip_address | IP address of the container (if applicable) |
| container_ports | Published ports of the container |
+150
View File
@@ -0,0 +1,150 @@
// Generic Docker service module
// Creates and manages a Docker container with configurable options
module "system_globals" {
source = "../../00-globals/system"
}
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
}
dotenv = {
source = "germanbrew/dotenv"
}
}
}
locals {
network_mode = var.network_mode
container_name = var.container_name
image_name = "${var.image}:${var.tag}"
default_env_vars = {
TZ = module.system_globals.timezone
PUID = var.puid != null ? var.puid : module.system_globals.puid
PGID = var.pgid != null ? var.pgid : module.system_globals.pgid
}
env_vars = merge(var.env_vars, local.default_env_vars)
// Prepare ports configuration
ports_config = [
for port in var.ports : {
internal = port.internal
external = port.external
protocol = port.protocol
}
]
// Prepare volumes configuration
volumes_config = [
for volume in var.volumes : {
host_path = volume.host_path
container_path = volume.container_path
read_only = volume.read_only
}
]
// Merge provided labels with monitoring labels
merged_labels = merge(var.labels)
}
// Pull the Docker image
resource "docker_image" "service_image" {
name = local.image_name
keep_locally = var.keep_image_locally
pull_triggers = [var.tag]
}
// Create the Docker container
resource "docker_container" "service_container" {
name = local.container_name
image = docker_image.service_image.image_id
restart = var.restart_policy
# Set the network mode (bridge, host, etc.)
network_mode = local.network_mode
# Add host mappings (entries for /etc/hosts)
dynamic "host" {
for_each = var.host_mappings
content {
host = host.value.host
ip = host.value.ip
}
}
# Dynamically configure ports based on the provided list
dynamic "ports" {
for_each = local.ports_config
content {
internal = ports.value.internal
external = ports.value.external
protocol = ports.value.protocol
}
}
# Dynamically configure networks based on the provided list
dynamic "networks_advanced" {
for_each = var.networks
content {
name = networks_advanced.value
}
}
# Dynamically configure volumes based on the provided list
dynamic "volumes" {
for_each = local.volumes_config
content {
host_path = volumes.value.host_path
container_path = volumes.value.container_path
read_only = volumes.value.read_only
}
}
# Configure environment variables - map to array of strings
env = [for k, v in local.env_vars : "${k}=${v}"]
# Set container labels
dynamic "labels" {
for_each = local.merged_labels
content {
label = labels.key
value = labels.value
}
}
# Add container healthcheck if configured
dynamic "healthcheck" {
for_each = var.healthcheck != null ? [var.healthcheck] : []
content {
test = healthcheck.value.test
interval = healthcheck.value.interval
timeout = healthcheck.value.timeout
start_period = healthcheck.value.start_period
retries = healthcheck.value.retries
}
}
# Set resource limits if specified
memory = var.memory_limit
memory_swap = var.memory_swap_limit
cpu_shares = var.cpu_shares
# Other container options
dns = var.dns
dns_search = var.dns_search
hostname = var.hostname
domainname = var.domainname
user = var.user
working_dir = var.working_dir
command = var.command
entrypoint = var.entrypoint
privileged = var.privileged
# Set log options
log_driver = var.log_driver
log_opts = var.log_opts
}
@@ -0,0 +1,24 @@
output "container_name" {
description = "Name of the Docker container"
value = docker_container.service_container.name
}
output "container_id" {
description = "ID of the Docker container"
value = docker_container.service_container.id
}
output "image_id" {
description = "ID of the Docker image"
value = docker_image.service_image.id
}
output "ip_address" {
description = "IP address of the container (if applicable)"
value = docker_container.service_container.network_data != null ? docker_container.service_container.network_data[0].ip_address : null
}
output "container_ports" {
description = "Published ports of the container"
value = docker_container.service_container.ports
}
@@ -0,0 +1,202 @@
variable "container_name" {
description = "Name of the Docker container"
type = string
}
variable "image" {
description = "Docker image name"
type = string
}
variable "tag" {
description = "Docker image tag"
type = string
default = "latest"
}
variable "keep_image_locally" {
description = "Whether to keep the Docker image locally after pulling"
type = bool
default = true
}
variable "restart_policy" {
description = "Docker restart policy (no, always, unless-stopped, on-failure)"
type = string
default = "always"
}
variable "network_mode" {
description = "Docker network mode (bridge, host, etc.)"
type = string
default = "bridge"
}
variable "ports" {
description = "List of port mappings"
type = list(object({
internal = number
external = number
protocol = string
}))
default = []
}
variable "networks" {
description = "List of networks to connect the container to"
type = list(string)
default = []
}
variable "volumes" {
description = "List of volume mappings"
type = list(object({
host_path = string
container_path = string
read_only = bool
}))
default = []
}
variable "env_vars" {
description = "Environment variables for the container"
type = map(string)
default = {}
sensitive = true
}
variable "puid" {
description = "User ID for the container"
type = number
default = null
}
variable "pgid" {
description = "Group ID for the container"
type = number
default = null
}
variable "labels" {
description = "Docker container labels"
type = map(string)
default = {}
}
variable "managed_by_caddy" {
description = "Enable mounting container through labels"
type = bool
default = true
}
variable "host_mappings" {
description = "Additional host mappings for the container (/etc/hosts entries)"
type = list(object({
host = string
ip = string
}))
default = []
}
variable "healthcheck" {
description = "Container healthcheck configuration"
type = object({
test = list(string)
interval = string
timeout = string
start_period = optional(string)
retries = number
})
default = null
}
// Resource limits
variable "memory_limit" {
description = "Memory limit for the container (in MB)"
type = number
default = null
}
variable "memory_swap_limit" {
description = "Memory swap limit for the container (in MB)"
type = number
default = null
}
variable "cpu_shares" {
description = "CPU shares for the container (relative weight)"
type = number
default = null
}
// Networking options
variable "dns" {
description = "DNS servers for the container"
type = list(string)
default = null
}
variable "dns_search" {
description = "DNS search domains for the container"
type = list(string)
default = null
}
variable "hostname" {
description = "Container hostname"
type = string
default = null
}
variable "domainname" {
description = "Container domainname"
type = string
default = null
}
// Execution options
variable "user" {
description = "User to run commands as inside the container"
type = string
default = ""
}
variable "working_dir" {
description = "Working directory inside the container"
type = string
default = null
}
variable "command" {
description = "Command to run when starting the container"
type = list(string)
default = null
}
variable "entrypoint" {
description = "Entrypoint for the container"
type = list(string)
default = null
}
variable "privileged" {
description = "Run container in privileged mode"
type = bool
default = false
}
// Logging options
variable "log_driver" {
description = "Log driver for the container"
type = string
default = "json-file"
}
variable "log_opts" {
description = "Log driver options"
type = map(string)
default = {
max-size = "10m"
max-file = "3"
}
}
@@ -0,0 +1,92 @@
terraform {
required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
variable "image_tag" {
description = "The tag for the JellyFin container image. Default: Latest"
type = string
default = "latest"
}
variable "volume_path" {
description = "Base directory for volumes"
type = string
}
variable "networks" {
description = "List of networks to which the container should be attached"
type = list(string)
default = []
}
variable "user_id" {
description = "User ID for container permissions"
type = string
default = "1000"
}
variable "group_id" {
description = "Group ID for container permissions"
type = string
default = "1000"
}
variable "timezone" {
description = "Timezone for the container"
type = string
default = "Europe/Helsinki"
}
locals {
container_name = "jellyfin"
jellyfin_image = "docker.io/jellyfin/jellyfin"
jellyfin_tag = var.image_tag
env_file = "${path.module}/.env"
jellyfin_internal_port = 8096
jellyfin_volumes = [
{
host_path = "/mnt/storage/media"
container_path = "/media"
read_only = true
},
{
host_path = "${volume_path}/${container_name}/config"
container_path = "/config"
},{
host_path = "${volume_path}/${container_name}/cache"
container_path = "/cache"
},
]
jellyfin_env_vars = {
PUID = var.user_id
PGID = var.group_id
TZ = var.timezone
}
}
module "jellyfin" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = local.jellyfin_image
tag = local.jellyfin_tag
volumes = local.jellyfin_volumes
env_vars = local.jellyfin_env_vars
networks = concat(var.networks)
restart_policy = "always"
}
output "service_definition" {
description = "General service definition with optional ingress configuration"
value = {
name = local.container_name
primary_port = local.jellyfin_internal_port
endpoint = "http://${local.container_name}:${local.jellyfin_internal_port}"
subdomains = ["tv"]
}
}
View File
+16
View File
@@ -0,0 +1,16 @@
terraform {
required_providers {
podman = {
source = "kreuzwerker/docker"
version = "~> 3.6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.5.1"
}
dotenv = {
source = "germanbrew/dotenv"
version = "1.2.5"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
locals {
module_dir = "../modules"
root_volume = module.system_globals.volume_host
volume_host = "${module.system_globals.volume_host}/appconfig"
}
module "system_globals" {
source = "${local.module_dir}/00-globals/system"
}
module "homelab_docker_network" {
source = "${local.module_dir}/01-networking/docker-network"
name = "default"
driver = "bridge"
attachable = true
subnet = "10.88.0.0/16"
}
+26
View File
@@ -0,0 +1,26 @@
output "service_definitions" {
description = "Service definitions for all services"
value = [
module.actualbudget.service_definition,
module.affine.service_definition,
module.calibre.service_definition,
module.copyparty.service_definition,
module.crawl4ai.service_definition,
module.emulatorjs.service_definition,
module.glance.service_definition,
module.linkwarden.service_definition,
module.n8n.service_definition,
module.n8n.n8n_mcp_service_definition,
module.nocodb.service_definition,
module.ntfy.service_definition,
module.portainer.service_definition,
module.pterodactyl_wings.service_definition,
module.pterodactyl_panel.service_definition,
module.searxng.service_definition
]
}
output "homelab_docker_network_name" {
description = "The name of the Docker network"
value = module.homelab_docker_network.name
}