36 Commits

Author SHA1 Message Date
e52d667cab Remove calibre 2025-10-03 15:50:04 +01:00
0fe34fb0e4 Pruning 2025-10-03 15:49:36 +01:00
Yuris Cakranegara
bce43c4a71 feat: add media server 2025-08-21 17:42:48 +10:00
Yuris Cakranegara
60e3a41ac5 feat(immich): proxy through cloudflare 2025-08-21 17:41:55 +10:00
Yuris Cakranegara
b9301fff36 feat(calibre): proxy through cloudflare 2025-08-21 17:41:48 +10:00
Yuris Cakranegara
80f8857dd2 feat(affine): proxy through cloudflare 2025-08-21 17:41:39 +10:00
Yuris Cakranegara
2c8c43ff68 feat(docker-service): allow adding group, capabilities, and device mappings 2025-08-21 17:41:10 +10:00
Yuris Cakranegara
4edfd642f3 fix(caddy): remove unused volume 2025-08-21 17:40:06 +10:00
Yuris Cakranegara
c59ebbcc8b style: tf formatting 2025-08-17 22:25:55 +10:00
Yuris Cakranegara
8ee71193bb feat: add immich 2025-08-17 22:24:47 +10:00
Yuris Cakranegara
4f5ee19cef feat(caddy): port mappings to standard 80/443 2025-08-17 20:47:42 +10:00
Yuris Cakranegara
9c46aa0d5b feat: add copyparty 2025-08-06 11:08:22 +10:00
Yuris Cakranegara
0a076a9af5 feat(docker-service): add shutdown grace period 2025-08-06 11:08:05 +10:00
Yuris Cakranegara
6595de4788 feat: restructure volume path 2025-08-06 11:07:18 +10:00
Yuris Cakranegara
3725c73bce feat(emulatorjs): define specific image version 2025-08-06 01:56:44 +10:00
Yuris Cakranegara
eefe369975 feat: add portainer 2025-08-06 01:56:00 +10:00
Yuris Cakranegara
ede6c52a40 feat(n8n): add n8n mcp server 2025-07-06 16:34:24 +10:00
Yuris Cakranegara
a63f144bf1 feat: add crawl4ai 2025-06-30 22:22:08 +10:00
Yuris Cakranegara
82d8ca0463 fix(nocodb): volume path 2025-06-30 22:21:55 +10:00
Yuris Cakranegara
c02ac6f961 feat: add nocodb 2025-06-30 09:45:47 +10:00
Yuris Cakranegara
b03034b742 feat(n8n): add redis 2025-06-29 04:08:07 +10:00
Yuris Cakranegara
26808e4ca6 feat(n8n): define private network subnet 2025-06-28 13:05:24 +10:00
Yuris Cakranegara
5cd8d36d97 feat: add glance 2025-06-28 13:04:30 +10:00
Yuris Cakranegara
9d5b083b32 feat: add calibre 2025-06-22 10:17:09 +10:00
Yuris Cakranegara
b73c7ab21d feat: add AFFiNE 2025-06-18 22:23:57 +10:00
Yuris Cakranegara
b5832c27a9 chore: update lockfile 2025-06-12 21:43:30 +10:00
Yuris Cakranegara
8d5008c6ca docs(emulatorjs): add README 2025-06-12 21:42:45 +10:00
Yuris Cakranegara
6709c85b0e feat(pterodactyl): define publish_via config 2025-06-12 21:39:58 +10:00
Yuris Cakranegara
e4ecd6dbcf feat(searxng): define publish_via config 2025-06-12 21:31:37 +10:00
Yuris Cakranegara
ec326cd75c feat(ntfy): define publish_via config 2025-06-12 21:28:20 +10:00
Yuris Cakranegara
1ab62c834c feat(n8n): define publish_via config 2025-06-12 21:23:50 +10:00
Yuris Cakranegara
45fc919f6d feat(linkwarden): define publish_via config 2025-06-12 21:21:28 +10:00
Yuris Cakranegara
4943c7c41b feat(actualbudget): define publish_via config 2025-06-12 21:21:09 +10:00
Yuris Cakranegara
715bcfbd7c refactor(cloudflare-tunnel): use cloudflare dns generic module 2025-06-12 21:06:06 +10:00
Yuris Cakranegara
af038e23ea feat: add caddy proxy 2025-06-12 20:55:58 +10:00
Yuris Cakranegara
d801b0b86d fix(pterodactyl): wings data path 2025-06-09 23:19:22 +10:00
42 changed files with 1435 additions and 697 deletions

View File

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

17
.terraform.lock.hcl generated
View File

@@ -47,6 +47,23 @@ provider "registry.opentofu.org/germanbrew/dotenv" {
] ]
} }
provider "registry.opentofu.org/hashicorp/local" {
version = "2.5.3"
hashes = [
"h1:mC9+u1eaUILTjxey6Ivyf/3djm//RNNze9kBVX/trng=",
"zh:32e1d4b0595cea6cda4ca256195c162772ddff25594ab4008731a2ec7be230bf",
"zh:48c390af0c87df994ec9796f04ec2582bcac581fb81ed6bb58e0671da1c17991",
"zh:4be7289c969218a57b40902e2f359914f8d35a7f97b439140cb711aa21e494bd",
"zh:4cf958e631e99ed6c8b522c9b22e1f1b568c0bdadb01dd002ca7dffb1c927764",
"zh:7a0132c0faca4c4c96aa70808effd6817e28712bf5a39881666ac377b4250acf",
"zh:7d60de08fac427fb045e4590d1b921b6778498eee9eb16f78c64d4c577bde096",
"zh:91003bee5981e99ec3925ce2f452a5f743827f9d0e131a86613549c1464796f0",
"zh:9fe2fe75977c8149e2515fb30c6cc6cfd57b225d4ce592c570d81a3831d7ffa3",
"zh:e210e6be54933ce93e03d0994e520ba289aa01b2c1f70e77afb8f2ee796b0fe3",
"zh:e8793e5f9422f2b31a804e51806595f335b827c9a38db18766960464566f21d5",
]
}
provider "registry.opentofu.org/hashicorp/random" { provider "registry.opentofu.org/hashicorp/random" {
version = "3.5.1" version = "3.5.1"
constraints = "~> 3.5.1" constraints = "~> 3.5.1"

View File

@@ -60,7 +60,7 @@ homelab/
│ └── docker-service/ # Generic module for deploying Docker containers │ └── docker-service/ # Generic module for deploying Docker containers
└── 20-services-apps/ # Application-specific wrapper modules └── 20-services-apps/ # Application-specific wrapper modules
├── jellyfin/ ├── jellyfin/
├── affine/ ├── calibre/
└── ... # Other application modules └── ... # Other application modules
└── services/ # Application services (Docker containers) └── services/ # Application services (Docker containers)

26
main.tf
View File

@@ -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"
} }
@@ -11,6 +19,10 @@ module "services" {
source = "./services" source = "./services"
} }
locals {
volume_host = "${module.system_globals.volume_host}/appdata"
}
module "homelab_cloudflared_tunnel" { module "homelab_cloudflared_tunnel" {
source = "./modules/01-networking/cloudflared-tunnel" source = "./modules/01-networking/cloudflared-tunnel"
cloudflare_account_id = module.cloudflare_globals.cloudflare_account_id cloudflare_account_id = module.cloudflare_globals.cloudflare_account_id
@@ -22,3 +34,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 = local.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 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
}

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 jellyfin (reverse-proxy only with direct IP exposure)
output "service_definition" {
description = "Service definition for a media server"
value = {
name = "jellyfin"
primary_port = 8096
endpoint = "http://jellyfin:8096"
subdomains = ["media"]
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,127 @@
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
])
}
// 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 = "80"
internal = "80"
protocol = "tcp"
},
{
external = "443"
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

@@ -72,8 +72,8 @@ module "homelab_tunnel" {
tunnel_name = "homelab-tunnel" tunnel_name = "homelab-tunnel"
ingress_rules = [ ingress_rules = [
{ {
hostname = "budget.${module.cloudflare_globals.domain}" hostname = "media.${module.cloudflare_globals.domain}"
service = "http://actualbudget:5006" service = "http://jellyfin:8096"
} }
] ]
} }

View File

@@ -26,16 +26,21 @@ resource "cloudflare_zero_trust_tunnel_cloudflared" "this" {
} }
locals { locals {
// Filter services to only include those that should be published via tunnel
tunnel_services = [
for service in var.service_definitions :
service if length(service.subdomains) > 0 && (service.publish_via == "tunnel" || service.publish_via == "both")
]
// Transform service definitions into ingress rules format, only for services with ingress_enabled // Transform service definitions into ingress rules format, only for services with ingress_enabled
service_ingress_rules = flatten([ service_ingress_rules = flatten([
for service in var.service_definitions : for service in local.tunnel_services :
// Only process services with subdomains AND where ingress is enabled (or default to true for backward compatibility) [
(length(service.subdomains) > 0) ? [
for subdomain in service.subdomains : { for subdomain in service.subdomains : {
hostname = "${subdomain}.${var.domain}" hostname = "${subdomain}.${var.domain}"
service = service.endpoint service = service.endpoint
} }
] : [] ]
]) ])
// Combine manual ingress rules and service-generated ones // Combine manual ingress rules and service-generated ones
@@ -67,21 +72,18 @@ resource "cloudflare_zero_trust_tunnel_cloudflared_config" "this" {
} }
} }
// Create DNS record for each service module "dns_records" {
resource "cloudflare_record" "service" { source = "../../10-services-generic/cloudflare-dns"
for_each = {
for rule in local.all_ingress_rules : rule.hostname => rule
if rule.hostname != null && rule.hostname != ""
}
zone_id = var.cloudflare_zone_id zone_id = var.cloudflare_zone_id
name = split(".", each.value.hostname)[0] // Extract subdomain hostnames = [
content = "${cloudflare_zero_trust_tunnel_cloudflared.this.id}.cfargotunnel.com" for rule in local.all_ingress_rules :
type = "CNAME" rule.hostname if rule.hostname != null && rule.hostname != ""
proxied = true ]
target_content = "${cloudflare_zero_trust_tunnel_cloudflared.this.id}.cfargotunnel.com"
record_type = "CNAME"
proxied = true
} }
// Set up the Docker container
locals { locals {
container_name = var.container_name != "" ? var.container_name : "cloudflared-${var.tunnel_name}" container_name = var.container_name != "" ? var.container_name : "cloudflared-${var.tunnel_name}"
image_tag = var.image_tag != "" ? var.image_tag : "latest" image_tag = var.image_tag != "" ? var.image_tag : "latest"

View File

@@ -21,11 +21,6 @@ output "cname_target" {
value = "${cloudflare_zero_trust_tunnel_cloudflared.this.id}.cfargotunnel.com" value = "${cloudflare_zero_trust_tunnel_cloudflared.this.id}.cfargotunnel.com"
} }
output "dns_records" {
description = "Map of created DNS records"
value = { for k, v in cloudflare_record.service : k => v.hostname }
}
output "container_name" { output "container_name" {
description = "The name of the Cloudflared tunnel container" description = "The name of the Cloudflared tunnel container"
value = module.cloudflared.container_name value = module.cloudflared.container_name

View File

@@ -53,7 +53,8 @@ variable "service_definitions" {
name = string name = string
primary_port = number primary_port = number
endpoint = string endpoint = string
subdomains = optional(list(string), []) subdomains = optional(list(string), [])
publish_via = optional(string)
})) }))
default = [] 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
}

View File

@@ -139,15 +139,36 @@ resource "docker_container" "service_container" {
cpu_shares = var.cpu_shares cpu_shares = var.cpu_shares
# Other container options # Other container options
dns = var.dns dns = var.dns
dns_search = var.dns_search dns_search = var.dns_search
hostname = var.hostname hostname = var.hostname
domainname = var.domainname domainname = var.domainname
user = var.user user = var.user
working_dir = var.working_dir group_add = var.group_add
command = var.command working_dir = var.working_dir
entrypoint = var.entrypoint command = var.command
privileged = var.privileged entrypoint = var.entrypoint
privileged = var.privileged
destroy_grace_seconds = var.destroy_grace_seconds
# Linux capabilities controls
dynamic "capabilities" {
for_each = length(var.capabilities_add) > 0 || length(var.capabilities_drop) > 0 ? [1] : []
content {
add = var.capabilities_add
drop = var.capabilities_drop
}
}
# Device mappings
dynamic "devices" {
for_each = var.devices
content {
host_path = devices.value.host_path
container_path = devices.value.container_path
permissions = devices.value.permissions
}
}
# Set log options # Set log options
log_driver = var.log_driver log_driver = var.log_driver

View File

@@ -179,12 +179,48 @@ variable "entrypoint" {
default = null default = null
} }
variable "group_add" {
description = "Additional groups to add to the container"
type = list(string)
default = []
}
variable "privileged" { variable "privileged" {
description = "Run container in privileged mode" description = "Run container in privileged mode"
type = bool type = bool
default = false default = false
} }
// Linux capabilities controls
variable "capabilities_add" {
description = "Linux capabilities to add to the container"
type = list(string)
default = []
}
variable "capabilities_drop" {
description = "Linux capabilities to drop from the container"
type = list(string)
default = []
}
// Devices to pass through to container
variable "devices" {
description = "List of device mappings for the container"
type = list(object({
host_path = string
container_path = string
permissions = string
}))
default = []
}
variable "destroy_grace_seconds" {
description = "Grace period in seconds before the container is destroyed"
type = number
default = 10
}
// Logging options // Logging options
variable "log_driver" { variable "log_driver" {
description = "Log driver for the container" description = "Log driver for the container"
@@ -195,8 +231,8 @@ variable "log_driver" {
variable "log_opts" { variable "log_opts" {
description = "Log driver options" description = "Log driver options"
type = map(string) type = map(string)
default = { default = {
max-size = "10m" max-size = "10m"
max-file = "3" max-file = "3"
} }
} }

View File

@@ -1,3 +0,0 @@
EMULATORJS_FRONTEND_PORT=5823
EMULATORJS_CONFIG_PORT=5824
EMULATORJS_BACKEND_PORT=5825

View File

@@ -1,78 +0,0 @@
terraform {
required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
variable "image_tag" {
description = "The tag for the EmulatorJS container image"
type = string
default = "latest"
}
variable "volume_path" {
description = "Base directory for volumes"
type = string
}
locals {
container_name = "emulatorjs"
image = "linuxserver/emulatorjs"
image_tag = var.image_tag != "" ? var.image_tag : "latest"
monitoring = true
env_file = "${path.module}/.env"
frontend_port = provider::dotenv::get_by_key("EMULATORJS_FRONTEND_PORT", local.env_file)
config_port = provider::dotenv::get_by_key("EMULATORJS_CONFIG_PORT", local.env_file)
backend_port = provider::dotenv::get_by_key("EMULATORJS_BACKEND_PORT", local.env_file)
ports = [
{
internal = 3000
external = local.config_port
protocol = "tcp"
},
{
internal = 80
external = local.frontend_port
protocol = "tcp"
},
{
internal = 4001
external = local.backend_port
protocol = "tcp"
}
]
volumes = [
{
host_path = "${var.volume_path}/config"
container_path = "/config"
read_only = false
},
{
host_path = "${var.volume_path}/data"
container_path = "/data"
read_only = false
}
]
}
module "emulatorjs" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = local.image
tag = local.image_tag
volumes = local.volumes
ports = local.ports
monitoring = local.monitoring
}
output "service_definition" {
description = "General service definition with optional ingress configuration"
value = {
name = module.emulatorjs.container_name
primary_port = local.frontend_port
endpoint = "http://${module.emulatorjs.container_name}:${local.frontend_port}"
}
}

View File

@@ -0,0 +1,77 @@
# Glance Module
This module deploys [Glance](https://glanceapp.io/), a dashboard application, as a Docker container in the homelab environment.
## Overview
The Glance module:
- Deploys the `glanceapp/glance` Docker container
- Persists configuration to a volume on the host
- Provides service definition for integration with networking modules
## Usage
```hcl
module "glance" {
source = "./modules/20-services-apps/glance"
volume_path = "/path/to/volumes/glance"
networks = ["homelab-network"]
}
```
## Variables
| Variable | Description | Type | Default |
| ------------- | ---------------------------------------------------------- | -------------- | ---------- |
| `image_tag` | Tag of the Glance image to use | `string` | `"latest"` |
| `volume_path` | Host path for Glance data volume | `string` | - |
| `networks` | List of networks to which the container should be attached | `list(string)` | - |
## Outputs
| Output | Description |
| -------------------- | ---------------------------------------------------------- |
| `service_definition` | Service definition for integration with networking modules |
## Service Definition
This module outputs a service definition that is used by the networking modules to expose the service.
```hcl
{
name = "glance"
primary_port = 4921
endpoint = "http://glance:4921"
subdomains = ["glance"]
publish_via = "tunnel" # Only publish through Cloudflare tunnel
}
```
## Data Persistence
Glance stores its configuration in the `/app/config` directory inside the container. This is mapped to a volume on the host at `${volume_path}/config`.
## Integration with Networking Modules
This service is configured to be exposed through a Cloudflare tunnel for secure remote access, set by `publish_via = "tunnel"`.
## Example Integration in Main Configuration
```hcl
module "glance" {
source = "./modules/20-services-apps/glance"
volume_path = module.system_globals.volume_host
networks = [module.services.homelab_docker_network_name]
}
# The service definition is automatically included in the services output
module "services" {
source = "./modules/services"
# ...
service_definitions = [
module.glance.service_definition,
# Other service definitions
]
}
```

View File

@@ -1,11 +1,11 @@
variable "image_tag" { variable "image_tag" {
description = "Tag of the ActualBudget image to use" description = "Tag of the Glance image to use"
type = string type = string
default = "latest" default = "latest"
} }
variable "volume_path" { variable "volume_path" {
description = "Host path for ActualBudget data volume" description = "Host path for Glance data volume"
type = string type = string
} }
@@ -15,22 +15,22 @@ variable "networks" {
} }
locals { locals {
container_name = "actualbudget" container_name = "glance"
image = "actualbudget/actual-server" image = "glanceapp/glance"
image_tag = var.image_tag != "" ? var.image_tag : "latest" image_tag = var.image_tag != "" ? var.image_tag : "latest"
monitoring = true monitoring = true
exposed_port = 5006 host_port = 8080
subdomains = ["budget"] subdomains = ["glance"]
default_volumes = [ default_volumes = [
{ {
container_path = "/data" container_path = "/app/config"
host_path = "${var.volume_path}/data" host_path = "${var.volume_path}/config"
read_only = false read_only = false
} },
] ]
} }
module "actualbudget" { module "glance" {
source = "../../10-services-generic/docker-service" source = "../../10-services-generic/docker-service"
container_name = local.container_name container_name = local.container_name
image = local.image image = local.image
@@ -44,8 +44,9 @@ output "service_definition" {
description = "General service definition with optional ingress configuration" description = "General service definition with optional ingress configuration"
value = { value = {
name = local.container_name name = local.container_name
primary_port = local.exposed_port primary_port = local.host_port
endpoint = "http://${local.container_name}:${local.exposed_port}" endpoint = "http://${local.container_name}:${local.host_port}"
subdomains = local.subdomains subdomains = local.subdomains
publish_via = "tunnel"
} }
} }

View File

@@ -0,0 +1,14 @@
# You can find documentation for all the supported env variables at https://immich.app/docs/install/environment-variables
###################################################################################
# Required database configuration (used by Terraform to configure Postgres & Immich)
###################################################################################
# PostgreSQL username
DB_USERNAME=postgres
# PostgreSQL password (use only A-Za-z0-9 characters)
DB_PASSWORD=postgres
# PostgreSQL database name
DB_DATABASE_NAME=immich

View File

@@ -0,0 +1,110 @@
# Immich Module
This module deploys [Immich](https://immich.app/), a high-performance self-hosted photo and video backup solution, as Docker containers in the homelab environment.
## Overview
The Immich module:
- Deploys four Docker containers:
- `immich-server`: The main Immich API/UI server (port 2283)
- `immich-machine-learning`: The ML service for search, faces, and embeddings
- `immich-postgres`: Immich-tuned PostgreSQL database
- `immich-redis`: Valkey/Redis-compatible cache
- Creates a dedicated Docker network (`immich-network`) for inter-container communication
- Persists data to volumes on the host
- Provides a service definition for integration with networking modules
## Usage
```hcl
module "immich" {
source = "./modules/20-services-apps/immich"
appdata_path = "/path/to/appdata/immich"
library_path = "/path/to/data/media/photos"
networks = ["homelab-network"]
}
```
## Variables
| Variable | Description | Type | Default |
| --------------- | -------------------------------------------------------------------------------- | -------------- | ---------- |
| `image_tag` | Tag of the Immich images to use (`server` and `machine-learning`) | `string` | `"release"` |
| `appdata_path` | Base host path for Immich app data (e.g., PostgreSQL data and internal configs) | `string` | - |
| `library_path` | Base host path for user library data and ML cache | `string` | - |
| `networks` | List of additional networks to which the server should attach | `list(string)` | `[]` |
## Outputs
| Output | Description |
| -------------------- | ---------------------------------------------------------- |
| `service_definition` | Service definition for integration with networking modules |
## Service Definition
This module outputs a service definition used by networking modules to expose the service.
```hcl
{
name = "immich-server"
primary_port = 2283
endpoint = "http://immich-server:2283"
subdomains = ["photos"]
publish_via = "reverse_proxy"
}
```
## Environment Variables
Only the database credentials are expected in a `.env` file in this module directory and are read using the `dotenv` Terraform provider. Everything else is configured directly in Terraform.
Required in `modules/20-services-apps/immich/.env`:
- `DB_USERNAME`: PostgreSQL user
- `DB_PASSWORD`: PostgreSQL password
- `DB_DATABASE_NAME`: Database name
A ready-to-copy `modules/20-services-apps/immich/.env.example` is provided.
## Data Persistence
Immich stores data in the following volumes:
1. Library storage: `/data` in `immich-server`, mapped to `${library_path}/library` on the host
2. ML model cache: `/cache` in `immich-machine-learning`, mapped to `${library_path}/machine-learning/cache` on the host
3. PostgreSQL data: `/var/lib/postgresql/data` in `immich-postgres`, mapped to `${appdata_path}/postgres/pgdata` on the host
## Networking
The module creates a dedicated Docker network named `immich-network` for communication between Immich components. The Immich server container is also attached to any additional networks specified in the `networks` variable, allowing it to communicate with other services in the homelab.
## Dependencies
- `immich-server` depends on `immich-postgres` and `immich-redis`
- `immich-postgres` and `immich-redis` include healthchecks
- The ML service is independent and discovered by the server internally; tuning can be done via the Immich admin UI
## Integration with Networking Modules
This service is configured to be exposed through the Caddy reverse proxy, set by `publish_via = "reverse_proxy"`.
## Example Integration in Main Configuration
```hcl
module "immich" {
source = "./modules/20-services-apps/immich"
appdata_path = "${module.system_globals.volume_host}/appdata/immich"
library_path = "${module.system_globals.volume_host}/data/media/photos"
networks = [module.services.homelab_docker_network_name]
}
# The service definition is automatically included in the services output
module "services" {
source = "./modules/services"
# ...
service_definitions = [
module.immich.service_definition,
# Other service definitions
]
}

View File

@@ -0,0 +1,197 @@
terraform {
required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
variable "image_tag" {
description = "The tag for the Immich container images (server and machine-learning)"
type = string
default = "release"
}
variable "appdata_path" {
description = "Base directory for Immich app data"
type = string
}
variable "library_path" {
description = "Base directory for Immich library data"
type = string
}
variable "networks" {
description = "List of networks to which the Immich server should be attached (in addition to the module network)"
type = list(string)
default = []
}
locals {
env_file = "${path.module}/.env"
monitoring = true
# Container names
server_name = "immich-server"
ml_name = "immich-machine-learning"
redis_name = "immich-redis"
postgres_name = "immich-postgres"
# Images and tags
server_image = "ghcr.io/immich-app/immich-server"
ml_image = "ghcr.io/immich-app/immich-machine-learning"
redis_image = "docker.io/valkey/valkey"
postgres_image = "ghcr.io/immich-app/postgres"
server_tag = var.image_tag
ml_tag = var.image_tag
redis_tag = "8-bookworm"
postgres_tag = "14-vectorchord0.4.3-pgvectors0.2.0"
# Ports
server_port = 2283
ml_port = 3003
# Volumes (host paths)
server_volumes = [
{
host_path = "${var.library_path}/data"
container_path = "/data"
read_only = false
}
]
ml_volumes = [
{
host_path = "${var.library_path}/ml/cache"
container_path = "/cache"
read_only = false
}
]
postgres_volumes = [
{
host_path = "${var.appdata_path}/postgres/pgdata"
container_path = "/var/lib/postgresql/data"
read_only = false
}
]
# Environment variables for Postgres
postgres_env_vars = {
POSTGRES_USER = provider::dotenv::get_by_key("DB_USERNAME", local.env_file)
POSTGRES_PASSWORD = provider::dotenv::get_by_key("DB_PASSWORD", local.env_file)
POSTGRES_DB = provider::dotenv::get_by_key("DB_DATABASE_NAME", local.env_file)
POSTGRES_INITDB_ARGS = "--data-checksums"
}
# Environment variables for Immich server
server_env_vars = {
# Database
DB_HOSTNAME = local.postgres_name
DB_PORT = "5432"
DB_USERNAME = provider::dotenv::get_by_key("DB_USERNAME", local.env_file)
DB_PASSWORD = provider::dotenv::get_by_key("DB_PASSWORD", local.env_file)
DB_DATABASE_NAME = provider::dotenv::get_by_key("DB_DATABASE_NAME", local.env_file)
# Redis
REDIS_HOSTNAME = local.redis_name
REDIS_PORT = "6379"
REDIS_DBINDEX = "0"
# General
IMMICH_MEDIA_LOCATION = "/data"
}
# Healthchecks
redis_healthcheck = {
test = ["CMD", "redis-cli", "ping"]
interval = "10s"
timeout = "5s"
retries = 5
start_period = "5s"
}
postgres_healthcheck = {
test = ["CMD", "pg_isready", "-U", provider::dotenv::get_by_key("DB_USERNAME", local.env_file), "-d", provider::dotenv::get_by_key("DB_DATABASE_NAME", local.env_file)]
interval = "10s"
timeout = "5s"
retries = 5
start_period = "5s"
}
}
# Dedicated network for Immich
module "immich_network" {
source = "../../01-networking/docker-network"
name = "immich-network"
driver = "bridge"
}
# Valkey (Redis) service
module "redis" {
source = "../../10-services-generic/docker-service"
container_name = local.redis_name
image = local.redis_image
tag = local.redis_tag
networks = [module.immich_network.name]
monitoring = local.monitoring
healthcheck = local.redis_healthcheck
}
# Postgres service (Immich custom image)
module "postgres" {
source = "../../10-services-generic/docker-service"
container_name = local.postgres_name
image = local.postgres_image
tag = local.postgres_tag
volumes = local.postgres_volumes
env_vars = local.postgres_env_vars
networks = [module.immich_network.name]
monitoring = local.monitoring
healthcheck = local.postgres_healthcheck
}
# Immich Machine Learning service
module "machine_learning" {
source = "../../10-services-generic/docker-service"
container_name = local.ml_name
image = local.ml_image
tag = local.ml_tag
volumes = local.ml_volumes
networks = [module.immich_network.name]
monitoring = local.monitoring
}
# Immich Server service
module "immich" {
source = "../../10-services-generic/docker-service"
container_name = local.server_name
image = local.server_image
tag = local.server_tag
ports = [
{
internal = local.server_port
external = local.server_port
protocol = "tcp"
}
]
volumes = local.server_volumes
env_vars = local.server_env_vars
networks = concat([module.immich_network.name], var.networks)
monitoring = local.monitoring
depends_on = [module.postgres, module.redis]
}
output "service_definition" {
description = "General service definition with optional ingress configuration"
value = {
name = local.server_name
primary_port = local.server_port
endpoint = "http://${local.server_name}:${local.server_port}"
subdomains = ["photos"]
publish_via = "reverse_proxy"
proxied = true
}
}

View File

@@ -0,0 +1,4 @@
# Optional values for Jellyfin module
# Only needed if you enable JELLYFIN_PublishedServerUrl in main.tf
HOSTNAME=example.com

View File

@@ -0,0 +1,87 @@
# Jellyfin Module
This module deploys Jellyfin as a Docker container and outputs a service definition to be published via your reverse proxy.
## Overview
- Container: `jellyfin` (LinuxServer.io)
- TCP 8096 for HTTP UI; UDP 7359/1900 for discovery/DLNA
- Config and media volumes mapped via variables
## Usage
```hcl
module "jellyfin" {
source = "./modules/20-services-apps/jellyfin"
volume_path = "/srv/appdata/jellyfin" # host path for Jellyfin config
data_path = "/srv/data" # host media root, mounted as /data
networks = [module.media_docker_network.name, module.homelab_docker_network.name]
}
```
## Variables
| Variable | Description | Type | Default |
| ------------- | --------------------------------------------- | -------------- | ------- |
| `volume_path` | Base directory for Jellyfin config | `string` | - |
| `data_path` | Base directory for media data mounted at /data | `string` | - |
| `networks` | List of networks to attach | `list(string)` | `[]` |
## Outputs
| Output | Description |
| -------------------- | -------------------------------------------- |
| `service_definition` | Service definition for integration with networking modules |
## Service Definition
This module outputs a service definition that is used by the networking modules to expose the service.
```hcl
{
name = "jellyfin"
primary_port = 8096
endpoint = "http://jellyfin:8096"
subdomains = ["stream"]
publish_via = "reverse_proxy"
proxied = false
}
```
## Environment Variables (.env)
This module optionally reads `HOSTNAME` from `.env` if you choose to publish a fixed external URL (see commented example in `main.tf`).
- `HOSTNAME` — your public domain (e.g., example.com). Used only if you enable `JELLYFIN_PublishedServerUrl`.
Note: `TZ`, `PUID`, and `PGID` are provided automatically by the generic docker-service module via system globals.
## Data Persistence
- `/config` -> `${volume_path}`
- `/data` -> `${data_path}`
Ensure the host paths exist and are writable by the container user.
## Dependencies
- No explicit inter-container dependencies. Healthcheck ensures readiness.
- UDP ports are exposed for discovery/DLNA.
## Integration with Networking Modules
This service is configured to be exposed through the Caddy reverse proxy, set by `publish_via = "reverse_proxy"`.
## Example Integration in Main Configuration
```hcl
# In services/main.tf
module "jellyfin" {
source = "${local.module_dir}/20-services-apps/jellyfin"
volume_path = "${local.volume_host}/jellyfin"
data_path = "${local.data_host}/media"
networks = [module.media_docker_network.name, module.homelab_docker_network.name]
}
```
The service definition is exported by the `services` module as `module.services.service_definitions` and consumed by networking modules in the root `main.tf`.

View File

@@ -0,0 +1,96 @@
terraform {
required_providers {
dotenv = { source = "germanbrew/dotenv" }
}
}
variable "volume_path" {
description = "Base directory for Jellyfin config"
type = string
}
variable "data_path" {
description = "Base directory for media data mounted at /data"
type = string
}
variable "networks" {
description = "List of networks to attach"
type = list(string)
default = []
}
locals {
env_file = "${path.module}/.env"
monitoring = true
container_name = "jellyfin"
image = "lscr.io/linuxserver/jellyfin"
tag = "latest"
internal_port = 8096
# UDP ports for DLNA/auto-discovery
udp_ports = [
{ internal = 7359, external = 7359, protocol = "udp" },
{ internal = 1900, external = 1900, protocol = "udp" }
]
volumes = [
{
host_path = var.volume_path,
container_path = "/config",
read_only = false
},
{
host_path = var.volume_path,
container_path = "/cache",
read_only = false
},
{
host_path = var.data_path,
container_path = "/data",
read_only = false
}
]
env_vars = {
# If you want to publish external URL, uncomment the following and set HOSTNAME in .env
JELLYFIN_PublishedServerUrl = "${provider::dotenv::get_by_key("HOSTNAME", local.env_file)}/jellyfin"
}
# Intel VAAPI/QSV: map the entire /dev/dri directory (per linuxserver/jellyfin docs)
devices = [
{
host_path = "/dev/dri/renderD128",
container_path = "/dev/dri/renderD128",
permissions = "rwm"
},
{
host_path = "/dev/dri/card0",
container_path = "/dev/dri/card0",
permissions = "rwm"
}
]
}
module "jellyfin" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = local.image
tag = local.tag
volumes = local.volumes
env_vars = local.env_vars
networks = var.networks
monitoring = local.monitoring
ports = local.udp_ports
devices = local.devices
}
output "service_definition" {
description = "Service definition for Jellyfin (reverse proxy)"
value = {
name = local.container_name
primary_port = local.internal_port
endpoint = "http://${local.container_name}:${local.internal_port}"
subdomains = ["stream"]
publish_via = "reverse_proxy"
proxied = true
}
}

View File

@@ -0,0 +1,90 @@
# Linkwarden Module
This module deploys [Linkwarden](https://linkwarden.app/), a self-hosted bookmark manager and link archive, as Docker containers in the homelab environment.
## Overview
The Linkwarden module:
- Deploys two Docker containers:
- `linkwarden`: The main application server (Next.js)
- `postgres`: A PostgreSQL database backend
- Persists data to volumes on the host
- Provides service definition for integration with networking modules
## Usage
```hcl
module "linkwarden" {
source = "./modules/20-services-apps/linkwarden"
volume_path = "/path/to/volumes/linkwarden"
networks = ["homelab-network"]
}
```
## Variables
| Variable | Description | Type | Default |
| ------------- | ---------------------------------------------------------- | -------------- | ---------- |
| `image_tag` | Tag of the Linkwarden image to use | `string` | `"latest"` |
| `volume_path` | Host path for Linkwarden and Postgres data volumes | `string` | - |
| `networks` | List of networks to which containers should be attached | `list(string)` | - |
## Outputs
| Output | Description |
| -------------------- | ---------------------------------------------------------- |
| `service_definition` | Service definition for integration with networking modules |
## Service Definition
This module outputs a service definition that is used by the networking modules to expose the service.
```hcl
{
name = "linkwarden"
primary_port = 3000
endpoint = "http://linkwarden:3000"
subdomains = ["links"]
publish_via = "tunnel" # Only publish through Cloudflare tunnel
}
```
## Environment Variables
Linkwarden requires several environment variables to function properly. These are stored in a `.env` file in the module directory and read using the `dotenv` Terraform provider:
- `NEXTAUTH_SECRET`: A secret key for NextAuth.js
- `NEXTAUTH_URL`: The public URL where Linkwarden will be accessed
- `POSTGRES_PASSWORD`: Password for the PostgreSQL database
## Data Persistence
Linkwarden stores its data in two volumes:
1. Linkwarden data: `/data/data` in the container, mapped to `${volume_path}/data` on the host
2. PostgreSQL data: `/var/lib/postgresql/data` in the container, mapped to `${volume_path}/pgdata` on the host
## Integration with Networking Modules
This service is configured to be exposed through a Cloudflare tunnel for secure remote access, set by `publish_via = "tunnel"`.
## Example Integration in Main Configuration
```hcl
module "linkwarden" {
source = "./modules/20-services-apps/linkwarden"
volume_path = module.system_globals.volume_host
networks = [module.services.homelab_docker_network_name]
}
# The service definition is automatically included in the services output
module "services" {
source = "./modules/services"
# ...
service_definitions = [
module.linkwarden.service_definition,
# Other service definitions
]
}
```

View File

@@ -90,5 +90,6 @@ output "service_definition" {
primary_port = local.internal_port primary_port = local.internal_port
endpoint = "http://${local.container_name}:${local.internal_port}" endpoint = "http://${local.container_name}:${local.internal_port}"
subdomains = ["links"] subdomains = ["links"]
publish_via = "tunnel"
} }
} }

View File

@@ -1,10 +0,0 @@
POSTGRES_USER=admin
POSTGRES_PASSWORD=
POSTGRES_DB=n8n
POSTGRES_NON_ROOT_USER=
POSTGRES_NON_ROOT_PASSWORD=
N8N_HOST=localhost
N8N_PORT=5678
N8N_PROTOCOL=http
WEBHOOK_URL=https://n8n.yourdomain.com/
NODE_FUNCTION_ALLOW_EXTERNAL=*

View File

@@ -1,13 +0,0 @@
#!/bin/bash
set -e;
if [ -n "${POSTGRES_NON_ROOT_USER:-}" ] && [ -n "${POSTGRES_NON_ROOT_PASSWORD:-}" ]; then
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER ${POSTGRES_NON_ROOT_USER} WITH PASSWORD '${POSTGRES_NON_ROOT_PASSWORD}';
GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_NON_ROOT_USER};
GRANT CREATE ON SCHEMA public TO ${POSTGRES_NON_ROOT_USER};
EOSQL
else
echo "SETUP INFO: No Environment variables given!"
fi

View File

@@ -1,133 +0,0 @@
terraform {
required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
variable "image_tag" {
description = "The tag for the n8n container image"
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 = []
}
locals {
container_name = "n8n"
database_name = "n8n-postgres"
n8n_image = "docker.n8n.io/n8nio/n8n"
database_image = "postgres"
n8n_tag = var.image_tag != "" ? var.image_tag : "latest"
database_tag = "16"
monitoring = true
env_file = "${path.module}/.env"
n8n_internal_port = 5678
# Define volumes
n8n_volumes = [
{
host_path = "${var.volume_path}/n8n_storage/_data"
container_path = "/home/node/.n8n"
read_only = false
}
]
database_volumes = [
{
host_path = "${var.volume_path}/db_storage/_data"
container_path = "/var/lib/postgresql/data"
read_only = false
},
{
host_path = "${var.volume_path}/init-data.sh"
container_path = "/docker-entrypoint-initdb.d/init-data.sh"
read_only = false
}
]
# Environment variables for the database
database_env_vars = {
POSTGRES_USER = provider::dotenv::get_by_key("POSTGRES_USER", local.env_file)
POSTGRES_PASSWORD = provider::dotenv::get_by_key("POSTGRES_PASSWORD", local.env_file)
POSTGRES_DB = provider::dotenv::get_by_key("POSTGRES_DB", local.env_file)
POSTGRES_NON_ROOT_USER = provider::dotenv::get_by_key("POSTGRES_NON_ROOT_USER", local.env_file)
POSTGRES_NON_ROOT_PASSWORD = provider::dotenv::get_by_key("POSTGRES_NON_ROOT_PASSWORD", local.env_file)
}
# Environment variables for n8n
n8n_env_vars = {
DB_TYPE = "postgresdb"
DB_POSTGRESDB_HOST = local.database_name
DB_POSTGRESDB_PORT = 5432
DB_POSTGRESDB_DATABASE = provider::dotenv::get_by_key("POSTGRES_DB", local.env_file)
DB_POSTGRESDB_USER = provider::dotenv::get_by_key("POSTGRES_NON_ROOT_USER", local.env_file)
DB_POSTGRESDB_PASSWORD = provider::dotenv::get_by_key("POSTGRES_NON_ROOT_PASSWORD", local.env_file)
N8N_HOST = provider::dotenv::get_by_key("N8N_HOST", local.env_file)
N8N_PORT = provider::dotenv::get_by_key("N8N_PORT", local.env_file)
N8N_PROTOCOL = provider::dotenv::get_by_key("N8N_PROTOCOL", local.env_file)
WEBHOOK_URL = provider::dotenv::get_by_key("WEBHOOK_URL", local.env_file)
NODE_FUNCTION_ALLOW_EXTERNAL = provider::dotenv::get_by_key("NODE_FUNCTION_ALLOW_EXTERNAL", local.env_file)
}
# Healthcheck configuration for the database
database_healthcheck = {
test = ["CMD-SHELL", "pg_isready -h localhost -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval = "5s"
timeout = "5s"
retries = 10
start_period = "10s"
}
}
module "n8n_network" {
source = "../../01-networking/docker-network"
name = "n8n-network"
driver = "bridge"
}
# Create the PostgreSQL container
module "postgres" {
source = "../../10-services-generic/docker-service"
container_name = local.database_name
image = local.database_image
tag = local.database_tag
volumes = local.database_volumes
env_vars = local.database_env_vars
networks = [module.n8n_network.name]
monitoring = local.monitoring
healthcheck = local.database_healthcheck
}
# Create the n8n container
module "n8n" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = local.n8n_image
tag = local.n8n_tag
volumes = local.n8n_volumes
env_vars = local.n8n_env_vars
networks = concat([module.n8n_network.name], var.networks)
monitoring = local.monitoring
depends_on = [module.postgres]
}
output "service_definition" {
description = "General service definition with optional ingress configuration"
value = {
name = local.container_name
primary_port = local.n8n_internal_port
endpoint = "http://${local.container_name}:${local.n8n_internal_port}"
subdomains = ["n8n"]
}
}

View File

@@ -1,56 +0,0 @@
variable "image_tag" {
description = "Tag of the ntfy image to use"
type = string
default = "latest"
}
variable "volume_path" {
description = "Host path for ntfy data volume"
type = string
}
variable "networks" {
description = "List of networks to which the container should be attached"
type = list(string)
}
locals {
container_name = "ntfy"
image = "binwiederhier/ntfy"
image_tag = var.image_tag != "" ? var.image_tag : "latest"
monitoring = true
exposed_port = 80
subdomains = ["ntfy"]
default_volumes = [
{
container_path = "/etc/ntfy"
host_path = "${var.volume_path}/app"
read_only = false
},
{
container_path = "/var/cache/ntfy"
host_path = "${var.volume_path}/cache"
read_only = false
}
]
}
module "ntfy" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = local.image
tag = local.image_tag
volumes = local.default_volumes
networks = var.networks
monitoring = local.monitoring
}
output "service_definition" {
description = "General service definition with optional ingress configuration"
value = {
name = local.container_name
primary_port = local.exposed_port
endpoint = "http://${local.container_name}:${local.exposed_port}"
subdomains = local.subdomains
}
}

View File

@@ -0,0 +1,78 @@
# Portainer Module
This module deploys [Portainer](https://www.portainer.io/), a lightweight management UI that allows you to easily manage your different Docker environments.
## Overview
The Portainer module:
- Deploys one Docker container: `portainer`.
- Mounts the Docker socket to allow Portainer to manage the Docker environment.
- Persists Portainer data to a volume on the host.
- Provides a service definition for integration with networking modules.
## Usage
```hcl
module "portainer" {
source = "./modules/20-services-apps/portainer"
volume_path = "/path/to/volumes/portainer"
networks = ["homelab-network"]
}
```
## Variables
| Variable | Description |
| ------------- | ---------------------------------------------------------------- |
| `image_tag` | Tag of the Portainer image to use |
| `volume_path` | Host path for Portainer data volume |
| `networks` | List of additional networks to which Portainer should be attached |
## Outputs
| Output | Description |
| -------------------- | ---------------------------------------------------------- |
| `service_definition` | Service definition for integration with networking modules |
## Service Definition
This module outputs a service definition that is used by the networking modules to expose the service.
```hcl
{
name = "portainer"
primary_port = 9000
endpoint = "http://portainer:9000"
subdomains = ["portainer"]
publish_via = "reverse_proxy"
}
```
## Data Persistence
Portainer stores its data in a single volume:
1. Portainer data: `/data` in the container, mapped to `${volume_path}/data` on the host.
It also mounts the Docker socket from `/var/run/docker.sock` on the host to `/var/run/docker.sock` in the container to manage Docker.
## Example Integration in Main Configuration
```hcl
module "portainer" {
source = "./modules/20-services-apps/portainer"
volume_path = "${module.system_globals.volume_host}/portainer"
networks = [module.services.homelab_docker_network_name]
}
# The service definition is automatically included in the services output
module "services" {
source = "./modules/services"
# ...
service_definitions = [
module.portainer.service_definition,
# Other service definitions
]
}
```

View File

@@ -1,13 +1,6 @@
terraform {
required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
variable "image_tag" { variable "image_tag" {
description = "The tag for the searxng container image" description = "The tag for the portainer container image"
type = string type = string
default = "latest" default = "latest"
} }
@@ -24,21 +17,30 @@ variable "networks" {
} }
locals { locals {
container_name = "searxng" container_name = "portainer"
image = "searxng/searxng" image = "portainer/portainer-ce"
tag = var.image_tag != "" ? var.image_tag : "latest" tag = var.image_tag
monitoring = true monitoring = true
internal_port = 8080 internal_port = 9000
exposed_port = 9000
# Define volumes
volumes = [ volumes = [
{ {
host_path = "${var.volume_path}/config" host_path = "/var/run/docker.sock"
container_path = "/etc/searxng" container_path = "/var/run/docker.sock"
read_only = false
},
{
host_path = "${var.volume_path}/data"
container_path = "/data"
read_only = false read_only = false
} }
] ]
} }
module "searxng" { # Create the portainer container
module "portainer" {
source = "../../10-services-generic/docker-service" source = "../../10-services-generic/docker-service"
container_name = local.container_name container_name = local.container_name
image = local.image image = local.image
@@ -46,14 +48,20 @@ module "searxng" {
volumes = local.volumes volumes = local.volumes
networks = var.networks networks = var.networks
monitoring = local.monitoring monitoring = local.monitoring
ports = [
{
internal = local.internal_port
external = local.exposed_port
protocol = "tcp"
},
]
} }
output "service_definition" { output "service_definition" {
description = "Service definition with ingress configuration" description = "General service definition with optional ingress configuration"
value = { value = {
name = local.container_name name = local.container_name
primary_port = local.internal_port primary_port = local.internal_port
endpoint = "http://${local.container_name}:${local.internal_port}" endpoint = "http://${local.container_name}:${local.internal_port}"
subdomains = ["search"]
} }
} }

View File

@@ -1,18 +0,0 @@
# Pterodactyl Panel Environment Settings
# Database Configuration
MYSQL_PASSWORD=secure_database_password_here
MYSQL_ROOT_PASSWORD=secure_root_password_here
MYSQL_DATABASE=pterodactyl
MYSQL_USER=pterodactyl
# Panel Configuration
APP_URL=https://panel.yourdomain.com
APP_TIMEZONE=Australia/Brisbane
APP_SERVICE_AUTHOR=email@example.com
APP_CORS_ALLOWED_ORIGINS=https://panel.yourdomain.com
TRUSTED_PROXIES="*" # Set this to your proxy IP
# Optional: Let's Encrypt Settings
# Uncomment and set to your email to use Let's Encrypt
# LE_EMAIL=admin@yourdomain.com

View File

@@ -1,163 +0,0 @@
terraform {
required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
module "smtp" {
source = "../../../00-globals/smtp"
}
variable "image_tag" {
description = "The tag for the Pterodactyl Panel container image"
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 = []
}
locals {
container_name = "pterodactyl-panel"
database_name = "pterodactyl-db"
cache_name = "pterodactyl-cache"
panel_image = "ghcr.io/pterodactyl/panel"
database_image = "mariadb"
cache_image = "redis"
panel_tag = var.image_tag != "" ? var.image_tag : "latest"
database_tag = "10.5"
cache_tag = "alpine"
monitoring = true
env_file = "${path.module}/.env"
# Volume paths
panel_volumes = [
{
host_path = "${var.volume_path}/var"
container_path = "/app/var"
read_only = false
},
{
host_path = "${var.volume_path}/nginx"
container_path = "/etc/nginx/http.d"
read_only = false
},
{
host_path = "${var.volume_path}/certs"
container_path = "/etc/letsencrypt"
read_only = false
},
{
host_path = "${var.volume_path}/logs"
container_path = "/app/storage/logs"
read_only = false
}
]
database_volumes = [
{
host_path = "${var.volume_path}/database"
container_path = "/var/lib/mysql"
read_only = false
}
]
# Environment variables
panel_env_vars = {
APP_URL = provider::dotenv::get_by_key("APP_URL", local.env_file)
APP_TIMEZONE = provider::dotenv::get_by_key("APP_TIMEZONE", local.env_file)
APP_SERVICE_AUTHOR = provider::dotenv::get_by_key("APP_SERVICE_AUTHOR", local.env_file)
APP_CORS_ALLOWED_ORIGINS = provider::dotenv::get_by_key("APP_CORS_ALLOWED_ORIGINS", local.env_file)
TRUSTED_PROXIES = provider::dotenv::get_by_key("TRUSTED_PROXIES", local.env_file)
MAIL_FROM = module.smtp.mail_from
MAIL_DRIVER = "smtp"
MAIL_HOST = module.smtp.mail_host
MAIL_PORT = module.smtp.mail_port
MAIL_USERNAME = module.smtp.mail_username
MAIL_PASSWORD = module.smtp.mail_password
MAIL_ENCRYPTION = "false"
DB_PASSWORD = provider::dotenv::get_by_key("MYSQL_PASSWORD", local.env_file)
APP_ENV = "production"
APP_ENVIRONMENT_ONLY = "false"
CACHE_DRIVER = "redis"
SESSION_DRIVER = "redis"
QUEUE_DRIVER = "redis"
REDIS_HOST = local.cache_name
DB_HOST = local.database_name
DB_PORT = "3306"
DB_DATABASE = provider::dotenv::get_by_key("MYSQL_DATABASE", local.env_file)
DB_USERNAME = provider::dotenv::get_by_key("MYSQL_USER", local.env_file)
}
database_env_vars = {
MYSQL_PASSWORD = provider::dotenv::get_by_key("MYSQL_PASSWORD", local.env_file)
MYSQL_ROOT_PASSWORD = provider::dotenv::get_by_key("MYSQL_ROOT_PASSWORD", local.env_file)
MYSQL_DATABASE = provider::dotenv::get_by_key("MYSQL_DATABASE", local.env_file)
MYSQL_USER = provider::dotenv::get_by_key("MYSQL_USER", local.env_file)
}
}
# Create a dedicated network for Pterodactyl
module "pterodactyl_network" {
source = "../../../01-networking/docker-network"
name = "ptero-panel"
driver = "bridge"
subnet = "172.20.0.0/16"
attachable = true
}
# Database container
module "database" {
source = "../../../10-services-generic/docker-service"
container_name = local.database_name
image = local.database_image
tag = local.database_tag
volumes = local.database_volumes
env_vars = local.database_env_vars
networks = [module.pterodactyl_network.name]
command = ["--default-authentication-plugin=mysql_native_password"]
monitoring = local.monitoring
}
# Cache container
module "cache" {
source = "../../../10-services-generic/docker-service"
container_name = local.cache_name
image = local.cache_image
tag = local.cache_tag
networks = [module.pterodactyl_network.name]
monitoring = local.monitoring
}
# Panel container
module "panel" {
source = "../../../10-services-generic/docker-service"
container_name = local.container_name
image = local.panel_image
tag = local.panel_tag
volumes = local.panel_volumes
env_vars = local.panel_env_vars
networks = concat([module.pterodactyl_network.name], var.networks)
monitoring = local.monitoring
depends_on = [module.database, module.cache]
}
output "service_definition" {
description = "General service definition with optional ingress configuration"
value = {
name = local.container_name
primary_port = 80
endpoint = "http://${local.container_name}:80"
subdomains = ["gameservers"]
}
}

View File

@@ -1,115 +0,0 @@
terraform {
required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
variable "image_tag" {
description = "The tag for the Pterodactyl Wings container image"
type = string
default = "v1.11.3"
}
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 = []
}
locals {
container_name = "pterodactyl-wings"
image = "ghcr.io/pterodactyl/wings"
image_tag = var.image_tag != "" ? var.image_tag : "v1.11.3"
monitoring = false
env_file = "${path.module}/.env"
subdomains = ["wings"]
# Volumes configuration
volumes = [
{
host_path = "/var/run/docker.sock"
container_path = "/var/run/docker.sock"
read_only = false
},
{
host_path = "/var/lib/docker/containers/"
container_path = "/var/lib/docker/containers/"
read_only = false
},
{
host_path = "/etc/ssl/certs"
container_path = "/etc/ssl/certs"
read_only = true
},
{
host_path = "${var.volume_path}/etc"
container_path = "/etc/pterodactyl/"
read_only = false
},
{
host_path = "${var.volume_path}/var/lib"
container_path = "/var/lib/pterodactyl/"
read_only = false
},
{
host_path = "${var.volume_path}/var/log"
container_path = "/var/log/pterodactyl/"
read_only = false
},
{
host_path = "${var.volume_path}/tmp"
container_path = "/tmp/pterodactyl/"
read_only = false
},
]
# Environment variables
env_vars = {
TZ = "Australia/Brisbane"
WINGS_UID = 988
WINGS_GID = 988
WINGS_USERNAME = "pterodactyl"
}
}
# Create a custom Docker network for wings
module "wings_network" {
source = "../../../01-networking/docker-network"
name = "ptero-wings"
driver = "bridge"
attachable = true
subnet = "172.32.0.0/16"
options = {
"com.docker.network.bridge.name" = "ptero-wings"
}
}
module "wings" {
source = "../../../10-services-generic/docker-service"
container_name = local.container_name
image = local.image
tag = local.image_tag
volumes = local.volumes
env_vars = local.env_vars
networks = concat([module.wings_network.name], var.networks)
monitoring = local.monitoring
privileged = true
}
output "service_definition" {
description = "General service definition with optional ingress configuration"
value = {
name = local.container_name
primary_port = 443
endpoint = "http://${local.container_name}:443"
subdomains = local.subdomains
}
}

72
services/main.tf Normal file → Executable file
View File

@@ -1,6 +1,8 @@
locals { locals {
module_dir = "../modules" module_dir = "../modules"
volume_host = module.system_globals.volume_host root_volume = module.system_globals.volume_host
volume_host = "${module.system_globals.volume_host}/appdata"
data_host = "${module.system_globals.volume_host}/data"
} }
module "system_globals" { module "system_globals" {
@@ -18,15 +20,41 @@ module "homelab_docker_network" {
subnet = "10.100.0.0/16" subnet = "10.100.0.0/16"
} }
module "actualbudget" { // Docker network used for media services
source = "${local.module_dir}/20-services-apps/actualbudget" module "media_docker_network" {
volume_path = "${local.volume_host}/actual" source = "${local.module_dir}/01-networking/docker-network"
name = "media-network"
driver = "bridge"
attachable = true
subnet = "10.110.0.0/16"
}
module "calibre" {
source = "${local.module_dir}/20-services-apps/calibre"
volume_path = "${local.volume_host}/calibre"
networks = [module.homelab_docker_network.name] networks = [module.homelab_docker_network.name]
} }
module "emulatorjs" { module "glance" {
source = "${local.module_dir}/20-services-apps/emulatorjs" source = "${local.module_dir}/20-services-apps/glance"
volume_path = "${local.volume_host}/emulatorjs" volume_path = "${local.volume_host}/glance"
networks = [module.homelab_docker_network.name]
}
module "immich" {
source = "${local.module_dir}/20-services-apps/immich"
appdata_path = "${local.volume_host}/immich"
library_path = "${local.data_host}/media/photos"
networks = [module.homelab_docker_network.name]
}
module "jellyfin" {
source = "${local.module_dir}/20-services-apps/jellyfin"
volume_path = "${local.volume_host}/jellyfin"
data_path = "${local.data_host}"
networks = [module.media_docker_network.name, module.homelab_docker_network.name]
} }
module "linkwarden" { module "linkwarden" {
@@ -35,32 +63,10 @@ module "linkwarden" {
networks = [module.homelab_docker_network.name] networks = [module.homelab_docker_network.name]
} }
module "ntfy" {
source = "${local.module_dir}/20-services-apps/ntfy" module "portainer" {
volume_path = "${local.volume_host}/ntfy" source = "${local.module_dir}/20-services-apps/portainer"
volume_path = "${local.volume_host}/portainer"
networks = [module.homelab_docker_network.name] networks = [module.homelab_docker_network.name]
} }
module "pterodactyl_panel" {
source = "${local.module_dir}/20-services-apps/pterodactyl/panel"
volume_path = "${local.volume_host}/pterodactyl/panel"
networks = [module.homelab_docker_network.name]
}
module "pterodactyl_wings" {
source = "${local.module_dir}/20-services-apps/pterodactyl/wings"
volume_path = "${local.volume_host}/pterodactyl/wings"
networks = [module.homelab_docker_network.name]
}
module "n8n" {
source = "${local.module_dir}/20-services-apps/n8n"
volume_path = "${local.volume_host}/n8n"
networks = [module.homelab_docker_network.name]
}
module "searxng" {
source = "${local.module_dir}/20-services-apps/searxng"
volume_path = "${local.volume_host}/searxng"
networks = [module.homelab_docker_network.name]
}

12
services/outputs.tf Normal file → Executable file
View File

@@ -4,14 +4,12 @@
output "service_definitions" { output "service_definitions" {
description = "Service definitions for all services" description = "Service definitions for all services"
value = [ value = [
module.actualbudget.service_definition, module.calibre.service_definition,
module.emulatorjs.service_definition, module.glance.service_definition,
module.immich.service_definition,
module.jellyfin.service_definition,
module.linkwarden.service_definition, module.linkwarden.service_definition,
module.ntfy.service_definition, module.portainer.service_definition
module.pterodactyl_wings.service_definition,
module.pterodactyl_panel.service_definition,
module.n8n.service_definition,
module.searxng.service_definition
] ]
} }