refactor: simplify project structure

This commit is contained in:
Yuris Cakranegara
2025-06-07 14:58:28 +10:00
parent 3ed0b402f5
commit c4775366e8
42 changed files with 441 additions and 1024 deletions

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# System
TIMEZONE="Australia/Brisbane"
DATA_DIR="/mnt/appdata"
PUID="1000"
PGID="1000"
# Cloudflare
CLOUDFLARE_API_TOKEN="your-cloudflare-api-token"
CLOUDFLARE_ACCOUNT_ID="your-cloudflare-account-id"
CLOUDFLARE_ZONE_ID="your-cloudflare-zone-id"
DOMAIN="yourdomain.com"

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ override.tf
override.tf.json override.tf.json
.terraformrc .terraformrc
terraform.rc terraform.rc
.env

23
.terraform.lock.hcl generated
View File

@@ -24,6 +24,29 @@ provider "registry.opentofu.org/cloudflare/cloudflare" {
] ]
} }
provider "registry.opentofu.org/germanbrew/dotenv" {
version = "1.2.5"
constraints = "1.2.5"
hashes = [
"h1:aUUdKCjUPHBgapuUb36pa8BUhWscSPpt5q1/JKNODsc=",
"zh:01d2c432515ef0ceffc321473a87c7571aaf068c31c36bed203c3450828e5ab5",
"zh:026dec6dcc688cfb6011e71e7c16219af02cc5acfa6ef4e6f803972c85b57eda",
"zh:31a63b727b5a5aea529bea1e557fbe04067c05c032db77afab61c2db0328dbf2",
"zh:3c53fb73bed50012019bdc83bc0502926a80c60e4c8f8fdade11e3a705baee32",
"zh:420b26f57d16fa8750da0e9a45b32846f39efc909de60b5e3b6e23596ab0ff15",
"zh:4e559274f355c79c9c5367b55f26d2a054d585502978dae15b715732d8717772",
"zh:84ab9b4024d53edbd67c83dc56a9af41089148d125e7dd1dab04ee402a8880e8",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:8f17d9cf82f08e5a6d451d80fec912aebf8ce8a0c880609666613b21de3393ad",
"zh:9f3c17e254c0f2a0eae5b53103976763241e049afcada375cd84af95aa012c18",
"zh:a3fffa256fe3eed985831f3ea90826ac7feab869f433a60c912a80d8d41783e8",
"zh:c6f6aa8249aaaefbf00b6c4c1eadb1166e469a07d36e738e4a3106b87e1b7340",
"zh:d2e89f25111485fb97d75b165bdfe06b47467e1ec62aedc884c9d30b3c138196",
"zh:d588bcb33fab3991fdd27d9d0a8a842244ecc45a32aa2b32ec12fc631c5dd5fb",
"zh:e78ce7620cfa89de13b93e55ba2bb51d68bac410b2f6fbea77d57ba9252f0f80",
]
}
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

@@ -1,12 +0,0 @@
# Homelab Environments
This directory contains environment-specific configurations that help organize your infrastructure modules into logical groupings.
Each subdirectory represents a category or environment that can be applied independently or together with others.
```
/environments/
├── core/ # Essential infrastructure (tunnel, monitoring)
├── services/ # Application services (ActualBudget, EmulatorJS)
└── network/ # (Future) Network configs
```

View File

@@ -1,17 +0,0 @@
// Core infrastructure components
// These are the foundational services that other services depend on
locals {
module_dir = "../../modules"
}
// Core monitoring and maintenance service
module "watchtower" {
source = "${local.module_dir}/20-services-apps/watchtower"
timezone = var.timezone
poll_interval = 86400
cleanup = true
enable_notifications = var.watchtower_enable_notifications
notification_url = var.watchtower_notification_url
}

View File

@@ -1,19 +0,0 @@
// Generic
variable "timezone" {
description = "Timezone for the system"
type = string
}
// Watchtower
variable "watchtower_enable_notifications" {
description = "Enable Watchtower update notifications"
type = bool
default = false
}
variable "watchtower_notification_url" {
description = "Webhook URL for Watchtower notifications (Discord, Slack, etc.)"
type = string
sensitive = true
default = ""
}

View File

@@ -1,45 +0,0 @@
// Network environment
// Contains configurations for network infrastructure
locals {
module_dir = "../../modules"
}
module "cloudflare_globals" {
source = "${local.module_dir}/00-globals/cloudflare"
cloudflare_api_token = var.cloudflare_api_token
cloudflare_account_id = var.cloudflare_account_id
cloudflare_zone_id = var.cloudflare_zone_id
domain = var.domain
}
module "homelab_docker_network" {
source = "${local.module_dir}/01-networking/docker-network"
name = "homelab-network"
driver = "bridge"
attachable = true
subnet = "10.100.0.0/16"
}
module "homelab_cloudflared_tunnel" {
source = "${local.module_dir}/01-networking/cloudflared-tunnel"
cloudflare_account_id = module.cloudflare_globals.cloudflare_account_id
cloudflare_zone_id = module.cloudflare_globals.cloudflare_zone_id
tunnel_name = "homelab"
container_name = "cloudflared-homelab"
ingress_rules = [
{
hostname = "budget.${var.domain}"
service = "http://actualbudget:5006"
},
]
networks = [module.homelab_docker_network.name]
monitoring = true
}

View File

@@ -1,36 +0,0 @@
output "cloudflare_account_id" {
description = "Cloudflare account ID"
value = module.cloudflare_globals.cloudflare_account_id
}
output "cloudflare_zone_id" {
description = "Cloudflare zone ID"
value = module.cloudflare_globals.cloudflare_zone_id
}
output "domain" {
description = "Base domain name"
value = module.cloudflare_globals.domain
}
// Docker network outputs
output "homelab_docker_network_name" {
description = "Name of the Docker network"
value = module.homelab_docker_network.name
}
// Tunnel outputs
output "homelab_cloudflared_tunnel_id" {
description = "ID of the Cloudflare tunnel"
value = module.homelab_cloudflared_tunnel.tunnel_id
}
output "homelab_cloudflared_tunnel_name" {
description = "Name of the Cloudflare tunnel"
value = module.homelab_cloudflared_tunnel.tunnel_name
}
output "homelab_cloudflared_tunnel_cname_target" {
description = "CNAME target for the tunnel"
value = module.homelab_cloudflared_tunnel.cname_target
}

View File

@@ -1,21 +0,0 @@
variable "cloudflare_api_token" {
description = "API token for Cloudflare with the necessary permissions"
type = string
sensitive = true
}
variable "cloudflare_account_id" {
description = "Cloudflare account ID"
type = string
}
variable "cloudflare_zone_id" {
description = "Cloudflare zone ID for the domain"
type = string
}
variable "domain" {
description = "Base domain name (e.g., example.com)"
type = string
}

View File

@@ -1,36 +0,0 @@
// Application services environment
// Contains configurations for all application services
// Import global Terraform settings
terraform {
# Include backend configuration if needed
# backend "local" { ... }
}
locals {
module_dir = "../../modules"
}
module "actualbudget" {
source = "${local.module_dir}/20-services-apps/actualbudget"
container_name = "actualbudget"
timezone = var.timezone
data_volume_path = "${var.data_dir}/actual/data"
port = var.actualbudget_port
networks = var.default_networks
}
module "emulatorjs" {
source = "${local.module_dir}/20-services-apps/emulatorjs"
container_name = "emulatorjs"
timezone = var.timezone
puid = var.puid
pgid = var.pgid
config_volume_path = "${var.data_dir}/emulatorjs/config"
data_volume_path = "${var.data_dir}/emulatorjs/data"
frontend_port = var.emulatorjs_frontend_port
config_port = var.emulatorjs_config_port
backend_port = var.emulatorjs_backend_port
}

View File

@@ -1,38 +0,0 @@
// Services environment outputs
// ActualBudget
output "actualbudget_container_name" {
description = "The name of the ActualBudget container"
value = module.actualbudget.container_name
}
output "actualbudget_container_id" {
description = "The ID of the ActualBudget container"
value = module.actualbudget.container_id
}
output "actualbudget_local_url" {
description = "The local URL to access ActualBudget"
value = module.actualbudget.local_url
}
// EmulatorJS
output "emulatorjs_container_name" {
description = "The name of the EmulatorJS container"
value = module.emulatorjs.container_name
}
output "emulatorjs_container_id" {
description = "The ID of the EmulatorJS container"
value = module.emulatorjs.container_id
}
output "emulatorjs_frontend_url" {
description = "The frontend URL for EmulatorJS"
value = module.emulatorjs.frontend_url
}
output "emulatorjs_config_url" {
description = "The configuration URL for EmulatorJS"
value = module.emulatorjs.config_url
}

View File

@@ -1,50 +0,0 @@
// Variables for the services environment
// Generic
variable "timezone" {
description = "Timezone for the system"
type = string
}
variable "puid" {
description = "User ID for the container"
type = number
}
variable "pgid" {
description = "Group ID for the container"
type = number
}
variable "data_dir" {
description = "Base directory for data volumes"
type = string
}
variable "default_networks" {
description = "List of networks to which the container should be attached"
type = list(string)
default = []
}
// ActualBudget
variable "actualbudget_port" {
description = "External port for the ActualBudget server"
type = number
}
// EmulatorJS
variable "emulatorjs_frontend_port" {
description = "External port for the EmulatorJS frontend"
type = number
}
variable "emulatorjs_config_port" {
description = "External port for the EmulatorJS configuration interface"
type = number
}
variable "emulatorjs_backend_port" {
description = "External port for the EmulatorJS backend"
type = number
}

59
main.tf
View File

@@ -1,49 +1,24 @@
// Root module that orchestrates all environments module "cloudflare_globals" {
// This unified approach keeps a single entry point while organizing by function source = "./modules/00-globals/cloudflare"
// Network infrastructure
module "network" {
source = "./environments/network"
// Cloudflare variables
cloudflare_api_token = var.cloudflare_api_token
cloudflare_account_id = var.cloudflare_account_id
cloudflare_zone_id = var.cloudflare_zone_id
domain = var.domain
} }
// Core infrastructure (monitoring, globals) module "watchtower" {
module "core" { source = "./modules/20-services-apps/watchtower"
source = "./environments/core"
depends_on = [module.network]
timezone = var.timezone
// Watchtower variables
watchtower_enable_notifications = var.watchtower_enable_notifications
watchtower_notification_url = var.watchtower_notification_url
} }
// Application services // Application services
module "services" { module "services" {
source = "./environments/services" source = "./services"
}
depends_on = [module.core, module.network]
module "homelab_cloudflared_tunnel" {
timezone = var.timezone source = "./modules/01-networking/cloudflared-tunnel"
puid = var.puid cloudflare_account_id = module.cloudflare_globals.cloudflare_account_id
pgid = var.pgid cloudflare_zone_id = module.cloudflare_globals.cloudflare_zone_id
data_dir = var.data_dir domain = module.cloudflare_globals.domain
tunnel_name = "homelab"
// ActualBudget variables container_name = "cloudflared-homelab"
actualbudget_port = var.actualbudget_port service_definitions = module.services.service_definitions
networks = [module.services.homelab_docker_network_name]
// EmulatorJS variables monitoring = true
emulatorjs_frontend_port = var.emulatorjs_frontend_port
emulatorjs_config_port = var.emulatorjs_config_port
emulatorjs_backend_port = var.emulatorjs_backend_port
// Docker network variables
default_networks = [module.network.homelab_docker_network_name]
} }

View File

@@ -0,0 +1,32 @@
terraform {
required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
data "dotenv_sensitive" "cloudflare_credentials" {}
data "dotenv" "cloudflare_config" {}
// Outputs
output "cloudflare_account_id" {
description = "Cloudflare account ID"
value = data.dotenv.cloudflare_config.entries.CLOUDFLARE_ACCOUNT_ID
}
output "cloudflare_zone_id" {
description = "Cloudflare zone ID"
value = data.dotenv.cloudflare_config.entries.CLOUDFLARE_ZONE_ID
}
output "domain" {
description = "Base domain name"
value = data.dotenv.cloudflare_config.entries.DOMAIN
}
output "cloudflare_api_token" {
description = "API token for Cloudflare"
value = data.dotenv_sensitive.cloudflare_credentials.entries.CLOUDFLARE_API_TOKEN
sensitive = true
}

View File

@@ -1,20 +0,0 @@
output "cloudflare_account_id" {
description = "Cloudflare account ID"
value = var.cloudflare_account_id
}
output "cloudflare_zone_id" {
description = "Cloudflare zone ID"
value = var.cloudflare_zone_id
}
output "domain" {
description = "Base domain name"
value = var.domain
}
output "cloudflare_api_token" {
description = "API token for Cloudflare"
value = var.cloudflare_api_token
sensitive = true
}

View File

@@ -1,20 +0,0 @@
variable "cloudflare_api_token" {
description = "API token for Cloudflare with tunnel, DNS, and zone management permissions"
type = string
sensitive = true
}
variable "cloudflare_account_id" {
description = "Cloudflare account ID"
type = string
}
variable "cloudflare_zone_id" {
description = "Cloudflare zone ID for your domain"
type = string
}
variable "domain" {
description = "Base domain name (e.g., example.com)"
type = string
}

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 "data_dir" {
description = "Base directory for data volumes"
value = data.dotenv.system_config.entries.DATA_DIR
}
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
}

View File

@@ -5,11 +5,9 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "~> 4.0"
} }
random = { random = {
source = "hashicorp/random" source = "hashicorp/random"
version = "~> 3.5.1"
} }
} }
} }
@@ -28,7 +26,23 @@ resource "cloudflare_zero_trust_tunnel_cloudflared" "this" {
} }
locals { locals {
all_ingress_rules = [for rule in var.ingress_rules : rule if rule != null] // Transform service definitions into ingress rules format, only for services with ingress_enabled
service_ingress_rules = flatten([
for service in var.service_definitions :
// Only process services with hostnames AND where ingress is enabled (or default to true for backward compatibility)
(length(service.hostnames) > 0) ? [
for hostname in service.hostnames : {
hostname = "${hostname}.${var.domain}"
service = service.endpoint
}
] : []
])
// Combine manual ingress rules and service-generated ones
all_ingress_rules = concat(
[for rule in var.ingress_rules : rule if rule != null],
local.service_ingress_rules
)
} }
// Configure tunnel routing // Configure tunnel routing
@@ -55,7 +69,10 @@ resource "cloudflare_zero_trust_tunnel_cloudflared_config" "this" {
// Create DNS record for each service // Create DNS record for each service
resource "cloudflare_record" "service" { resource "cloudflare_record" "service" {
for_each = { for rule in var.ingress_rules : rule.hostname => rule } 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 name = split(".", each.value.hostname)[0] // Extract subdomain

View File

@@ -1,5 +1,4 @@
// Variables for the Cloudflare tunnel module // Variables for the Cloudflare tunnel module
variable "cloudflare_account_id" { variable "cloudflare_account_id" {
description = "Cloudflare account ID" description = "Cloudflare account ID"
type = string type = string
@@ -10,6 +9,11 @@ variable "cloudflare_zone_id" {
type = string type = string
} }
variable "domain" {
description = "The domain name to use for creating DNS records"
type = string
}
variable "container_name" { variable "container_name" {
description = "Name of the Cloudflare tunnel container" description = "Name of the Cloudflare tunnel container"
type = string type = string
@@ -35,7 +39,7 @@ variable "tunnel_secret" {
} }
variable "ingress_rules" { variable "ingress_rules" {
description = "List of ingress rules for services to be exposed through the tunnel" description = "List of ingress rules to configure manually"
type = list(object({ type = list(object({
hostname = string hostname = string
service = string service = string
@@ -43,6 +47,17 @@ variable "ingress_rules" {
default = [] default = []
} }
variable "service_definitions" {
description = "List of service definitions containing name, endpoints and hostname configuration"
type = list(object({
name = string
primary_port = number
endpoint = string
hostnames = optional(list(string), [])
}))
default = []
}
variable "monitoring" { variable "monitoring" {
description = "Enable monitoring via Watchtower" description = "Enable monitoring via Watchtower"
type = bool type = bool

View File

@@ -1,11 +1,7 @@
// Docker Network Module
// This module creates a Docker network for container communication
terraform { terraform {
required_providers { required_providers {
docker = { docker = {
source = "kreuzwerker/docker" source = "kreuzwerker/docker"
version = "~> 3.6.0"
} }
} }
} }

View File

@@ -1,5 +1,4 @@
// Outputs for Docker Network module // Outputs for Docker Network module
output "network_id" { output "network_id" {
description = "The ID of the Docker network" description = "The ID of the Docker network"
value = docker_network.this.id value = docker_network.this.id

View File

@@ -68,7 +68,6 @@ terraform {
required_providers { required_providers {
docker = { docker = {
source = "kreuzwerker/docker" source = "kreuzwerker/docker"
version = ">= 3.0.0"
} }
} }
} }

View File

@@ -1,11 +1,16 @@
// Generic Docker service module // Generic Docker service module
// Creates and manages a Docker container with configurable options // Creates and manages a Docker container with configurable options
module "system_globals" {
source = "../../00-globals/system"
}
terraform { terraform {
required_providers { required_providers {
docker = { docker = {
source = "kreuzwerker/docker" source = "kreuzwerker/docker"
version = ">= 3.0.0" }
dotenv = {
source = "germanbrew/dotenv"
} }
} }
} }
@@ -15,6 +20,14 @@ locals {
container_name = var.container_name container_name = var.container_name
image_name = "${var.image}:${var.tag}" image_name = "${var.image}:${var.tag}"
default_env_vars = {
TZ = module.system_globals.timezone
PUID = module.system_globals.puid
PGID = module.system_globals.pgid
}
env_vars = merge(var.env_vars, local.default_env_vars)
// Prepare ports configuration // Prepare ports configuration
ports_config = [ ports_config = [
for port in var.ports : { for port in var.ports : {
@@ -88,7 +101,7 @@ resource "docker_container" "service_container" {
} }
# Configure environment variables - map to array of strings # Configure environment variables - map to array of strings
env = [for k, v in var.env_vars : "${k}=${v}"] env = [for k, v in local.env_vars : "${k}=${v}"]
# Set container labels # Set container labels
dynamic "labels" { dynamic "labels" {

View File

@@ -23,7 +23,7 @@ variable "keep_image_locally" {
variable "restart_policy" { variable "restart_policy" {
description = "Docker restart policy (no, always, unless-stopped, on-failure)" description = "Docker restart policy (no, always, unless-stopped, on-failure)"
type = string type = string
default = "unless-stopped" default = "always"
} }
variable "network_mode" { variable "network_mode" {

View File

@@ -1,20 +1,30 @@
// ActualBudget module for budgeting variable "image_tag" {
// This module configures an ActualBudget container with the specified volumes description = "Tag of the ActualBudget image to use"
type = string
default = "latest"
}
variable "volume_path" {
description = "Host path for ActualBudget data volume"
type = string
}
variable "networks" {
description = "List of networks to which the container should be attached"
type = list(string)
}
locals { locals {
container_name = var.container_name != "" ? var.container_name : "actualbudget" container_name = "actualbudget"
image = "actualbudget/actual-server"
image_tag = var.image_tag != "" ? var.image_tag : "latest" image_tag = var.image_tag != "" ? var.image_tag : "latest"
monitoring = true
default_env_vars = { exposed_port = 5006
TZ = var.timezone hostnames = ["budget"]
PUID = var.puid
PGID = var.pgid
}
default_volumes = [ default_volumes = [
{ {
container_path = "/data" container_path = "/data"
host_path = var.data_volume_path host_path = "${var.volume_path}/data"
read_only = false read_only = false
} }
] ]
@@ -22,28 +32,20 @@ locals {
module "actualbudget" { module "actualbudget" {
source = "../../10-services-generic/docker-service" source = "../../10-services-generic/docker-service"
container_name = local.container_name
container_name = var.container_name image = local.image
image = "actualbudget/actual-server" tag = local.image_tag
tag = var.image_tag
// Environment variables
env_vars = local.default_env_vars
// Port mapping
ports = [
{
internal = 5006
external = var.port
protocol = "tcp"
}
]
// Volume mapping
volumes = local.default_volumes volumes = local.default_volumes
// Enable monitoring for the container via Watchtower
monitoring = var.monitoring
networks = var.networks 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}"
hostnames = local.hostnames
}
} }

View File

@@ -1,24 +0,0 @@
output "container_name" {
description = "The name of the ActualBudget container"
value = module.actualbudget.container_name
}
output "container_id" {
description = "The ID of the ActualBudget container"
value = module.actualbudget.container_id
}
output "image_id" {
description = "The ID of the ActualBudget image"
value = module.actualbudget.image_id
}
output "ip_address" {
description = "The IP address of the ActualBudget container"
value = module.actualbudget.ip_address
}
output "local_url" {
description = "The local URL to access the ActualBudget interface"
value = "http://localhost:${var.port}"
}

View File

@@ -1,52 +0,0 @@
variable "container_name" {
description = "Name of the ActualBudget container"
type = string
default = "actualbudget"
}
variable "timezone" {
description = "Timezone for the container"
type = string
default = "UTC"
}
variable "image_tag" {
description = "Tag of the ActualBudget image to use"
type = string
default = "latest"
}
variable "port" {
description = "External port for ActualBudget server"
type = number
default = 5006
}
variable "data_volume_path" {
description = "Host path for ActualBudget data volume"
type = string
}
variable "puid" {
description = "User ID for the container"
type = number
default = 1000
}
variable "pgid" {
description = "Group ID for the container"
type = number
default = 1000
}
variable "monitoring" {
description = "Enable monitoring for the container via Watchtower"
type = bool
default = true
}
variable "networks" {
description = "List of networks to which the container should be attached"
type = list(string)
default = []
}

View File

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

View File

@@ -1,70 +1,78 @@
// EmulatorJS module for retro game emulation terraform {
// This module configures an EmulatorJS container with the specified volumes required_providers {
dotenv = {
locals { source = "germanbrew/dotenv"
container_name = var.container_name != "" ? var.container_name : "emulatorjs"
image_tag = var.image_tag != "" ? var.image_tag : "latest"
default_env_vars = {
TZ = var.timezone
} }
// Merge default env vars with any additional ones provided
env_vars = merge(local.default_env_vars, var.additional_env_vars)
// Default volumes for EmulatorJS
default_volumes = [
{
host_path = var.config_volume_path
container_path = "/config"
read_only = false
},
{
host_path = var.data_volume_path
container_path = "/data"
read_only = false
} }
]
// Merge default volumes with any additional ones provided
volumes = concat(local.default_volumes, var.additional_volumes)
} }
// Use the generic docker-service module to deploy EmulatorJS variable "image_tag" {
module "emulatorjs" { description = "The tag for the EmulatorJS container image"
source = "../../10-services-generic/docker-service" type = string
default = "latest"
}
container_name = local.container_name variable "volume_path" {
description = "Base directory for volumes"
type = string
}
locals {
container_name = "emulatorjs"
image = "linuxserver/emulatorjs" image = "linuxserver/emulatorjs"
tag = local.image_tag image_tag = var.image_tag != "" ? var.image_tag : "latest"
monitoring = true
restart_policy = var.restart_policy env_file = "${path.module}/.env"
network_mode = "bridge" 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)
env_vars = local.env_vars backend_port = provider::dotenv::get_by_key("EMULATORJS_BACKEND_PORT", local.env_file)
volumes = local.volumes
labels = var.labels
// Default ports for EmulatorJS
ports = [ ports = [
{ {
internal = 3000 internal = 3000
external = var.config_port external = local.config_port
protocol = "tcp" protocol = "tcp"
}, },
{ {
internal = 80 internal = 80
external = var.frontend_port external = local.frontend_port
protocol = "tcp" protocol = "tcp"
}, },
{ {
internal = 4001 internal = 4001
external = var.backend_port external = local.backend_port
protocol = "tcp" protocol = "tcp"
} }
] ]
volumes = [
// Enable monitoring for the container via Watchtower {
monitoring = var.monitoring 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

@@ -1,26 +0,0 @@
// Outputs for the EmulatorJS module
output "container_name" {
description = "Name of the created EmulatorJS container"
value = module.emulatorjs.container_name
}
output "container_id" {
description = "ID of the created EmulatorJS container"
value = module.emulatorjs.container_id
}
output "image_id" {
description = "ID of the EmulatorJS image used"
value = module.emulatorjs.image_id
}
output "frontend_url" {
description = "URL to access the EmulatorJS frontend interface"
value = "http://localhost:${var.frontend_port}"
}
output "config_url" {
description = "URL to access the EmulatorJS web configuration interface"
value = "http://localhost:${var.config_port}"
}

View File

@@ -1,91 +0,0 @@
variable "container_name" {
description = "Name for the EmulatorJS container"
type = string
default = "emulatorjs"
}
variable "image_tag" {
description = "The tag for the EmulatorJS container image"
type = string
default = "latest"
}
variable "restart_policy" {
description = "Restart policy for the container"
type = string
default = "unless-stopped"
}
variable "timezone" {
description = "Timezone for the container"
type = string
default = "Etc/UTC"
}
variable "puid" {
description = "User ID the container will run as"
type = number
default = 1000
}
variable "pgid" {
description = "Group ID the container will run as"
type = number
default = 1000
}
variable "config_volume_path" {
description = "Host path for the EmulatorJS config directory"
type = string
}
variable "data_volume_path" {
description = "Host path for the EmulatorJS data directory"
type = string
}
variable "frontend_port" {
description = "External port for the EmulatorJS frontend"
type = number
default = 3000
}
variable "config_port" {
description = "External port for the EmulatorJS configuration interface"
type = number
default = 8080
}
variable "backend_port" {
description = "External port for the EmulatorJS backend"
type = number
default = 4001
}
variable "additional_env_vars" {
description = "Additional environment variables for EmulatorJS"
type = map(string)
default = {}
}
variable "additional_volumes" {
description = "Additional volumes to mount in the container"
type = list(object({
host_path = string
container_path = string
read_only = bool
}))
default = []
}
variable "labels" {
description = "Labels to set on the container"
type = map(string)
default = {}
}
variable "monitoring" {
description = "Enable monitoring for the container via Watchtower"
type = bool
default = true
}

View File

@@ -0,0 +1,7 @@
WATCHTOWER_CLEANUP=true
WATCHTOWER_POLL_INTERVAL=86400
WATCHTOWER_INCLUDE_STOPPED=false
WATCHTOWER_REVIVE_STOPPED=false
WATCHTOWER_ROLLING_RESTART=true
WATCHTOWER_NOTIFICATIONS=shoutrrr
WATCHTOWER_NOTIFICATION_URL=discord://token@webhook_id

View File

@@ -1,58 +1,48 @@
// Watchtower module for automatic Docker container updates terraform {
// This module configures a Watchtower container that monitors and updates other containers required_providers {
dotenv = {
source = "germanbrew/dotenv"
}
}
}
variable "image_tag" {
description = "The tag for the Watchtower container image"
type = string
default = "latest"
}
locals { locals {
container_name = var.container_name != "" ? var.container_name : "watchtower" container_name = "watchtower"
image = "containrrr/watchtower"
image_tag = var.image_tag != "" ? var.image_tag : "latest" image_tag = var.image_tag != "" ? var.image_tag : "latest"
env_file = "${path.module}/.env"
default_env_vars = { env_vars = {
TZ = var.timezone WATCHTOWER_CLEANUP = provider::dotenv::get_by_key("WATCHTOWER_CLEANUP", local.env_file)
WATCHTOWER_CLEANUP = var.cleanup WATCHTOWER_POLL_INTERVAL = provider::dotenv::get_by_key("WATCHTOWER_POLL_INTERVAL", local.env_file)
WATCHTOWER_POLL_INTERVAL = var.poll_interval WATCHTOWER_INCLUDE_STOPPED = false
WATCHTOWER_INCLUDE_STOPPED = var.include_stopped WATCHTOWER_REVIVE_STOPPED = false
WATCHTOWER_REVIVE_STOPPED = var.revive_stopped WATCHTOWER_ROLLING_RESTART = true
WATCHTOWER_ROLLING_RESTART = var.rolling_restart WATCHTOWER_NOTIFICATION_URL = provider::dotenv::get_by_key("WATCHTOWER_NOTIFICATION_URL", local.env_file)
WATCHTOWER_NOTIFICATION_URL = var.notification_url WATCHTOWER_NOTIFICATIONS = provider::dotenv::get_by_key("WATCHTOWER_NOTIFICATIONS", local.env_file)
WATCHTOWER_NOTIFICATIONS = var.enable_notifications ? "shoutrrr" : ""
} }
// Merge default env vars with any additional ones provided volumes = [
env_vars = merge(local.default_env_vars, var.additional_env_vars)
// Default volumes for Docker socket access
default_volumes = [
{ {
host_path = "/var/run/docker.sock" host_path = "/var/run/docker.sock"
container_path = "/var/run/docker.sock" container_path = "/var/run/docker.sock"
read_only = true read_only = true
} }
] ]
// Merge default volumes with any additional ones provided
volumes = concat(local.default_volumes, var.additional_volumes)
} }
// Use the generic docker-service module to deploy Watchtower
module "watchtower" { module "watchtower" {
source = "../../10-services-generic/docker-service" source = "../../10-services-generic/docker-service"
container_name = local.container_name container_name = local.container_name
image = "containrrr/watchtower" image = local.image
tag = local.image_tag tag = local.image_tag
restart_policy = var.restart_policy
network_mode = "bridge"
env_vars = local.env_vars env_vars = local.env_vars
volumes = local.volumes volumes = local.volumes
labels = var.labels
// Watchtower doesn't typically expose ports but we'll include the option
ports = var.ports
// Add monitoring label if enabled
monitoring = var.monitoring
depends_on = []
} }

View File

@@ -1,14 +0,0 @@
output "container_name" {
description = "Name of the created Watchtower container"
value = module.watchtower.container_name
}
output "container_id" {
description = "ID of the created Watchtower container"
value = module.watchtower.container_id
}
output "image_id" {
description = "ID of the Watchtower image used"
value = module.watchtower.image_id
}

View File

@@ -1,103 +0,0 @@
variable "container_name" {
description = "Name for the Watchtower container"
type = string
default = "watchtower"
}
variable "image_tag" {
description = "The tag for the Watchtower container image"
type = string
default = "latest"
}
variable "restart_policy" {
description = "Restart policy for the container"
type = string
default = "unless-stopped"
}
variable "timezone" {
description = "Timezone for the container"
type = string
default = "Etc/UTC"
}
variable "cleanup" {
description = "Remove old images after updating"
type = bool
default = true
}
variable "poll_interval" {
description = "Poll interval (in seconds) for checking for updates"
type = number
default = 86400 // Default: check once per day
}
variable "include_stopped" {
description = "Include stopped containers when checking for updates"
type = bool
default = false
}
variable "revive_stopped" {
description = "Restart stopped containers after updating"
type = bool
default = false
}
variable "rolling_restart" {
description = "Restart containers one by one instead of all at once"
type = bool
default = true
}
variable "notification_url" {
description = "URL for sending update notifications via shoutrrr"
type = string
default = ""
}
variable "enable_notifications" {
description = "Enable shoutrrr notifications"
type = bool
default = false
}
variable "additional_env_vars" {
description = "Additional environment variables for Watchtower"
type = map(string)
default = {}
}
variable "additional_volumes" {
description = "Additional volumes to mount in the container"
type = list(object({
host_path = string
container_path = string
read_only = bool
}))
default = []
}
variable "labels" {
description = "Labels to set on the container"
type = map(string)
default = {}
}
variable "ports" {
description = "Ports to expose (Watchtower typically doesn't need ports exposed)"
type = list(object({
internal = number
external = number
protocol = string
}))
default = []
}
variable "monitoring" {
description = "Enable monitoring for the container"
type = bool
default = true
}

View File

@@ -1,33 +1,9 @@
// Root outputs that expose important information from each environment output "services" {
description = "Service definitions for all services"
// Network environment outputs value = [
output "cloudflare_domain" { for service in module.services.service_definitions : {
description = "Base domain for the homelab" name = service.name
value = module.network.domain endpoint = contains(keys(service), "hostnames") ? "${service.hostnames[0]}.${module.cloudflare_globals.domain}" : service.endpoint
} }
]
output "homelab_cloudflared_tunnel_name" {
description = "Name of the Cloudflare tunnel"
value = module.network.homelab_cloudflared_tunnel_name
}
output "homelab_cloudflared_tunnel_cname_target" {
description = "CNAME target for the Cloudflare tunnel"
value = module.network.homelab_cloudflared_tunnel_cname_target
}
// Service URLs
output "actualbudget_local_url" {
description = "Local URL for accessing ActualBudget"
value = module.services.actualbudget_local_url
}
output "emulatorjs_frontend_url" {
description = "URL for the EmulatorJS frontend"
value = module.services.emulatorjs_frontend_url
}
output "emulatorjs_config_url" {
description = "URL for the EmulatorJS configuration"
value = module.services.emulatorjs_config_url
} }

View File

@@ -12,9 +12,13 @@ terraform {
source = "hashicorp/random" source = "hashicorp/random"
version = "~> 3.5.1" version = "~> 3.5.1"
} }
dotenv = {
source = "germanbrew/dotenv"
version = "1.2.5"
}
} }
} }
provider "cloudflare" { provider "cloudflare" {
api_token = var.cloudflare_api_token api_token = module.cloudflare_globals.cloudflare_api_token
} }

30
services/main.tf Normal file
View File

@@ -0,0 +1,30 @@
locals {
module_dir = "../modules"
data_dir = module.system_globals.data_dir
}
module "system_globals" {
source = "${local.module_dir}/00-globals/system"
}
// Docker network used for modules that needs to be exposed to the internet
// using Cloudflared
module "homelab_docker_network" {
source = "${local.module_dir}/01-networking/docker-network"
name = "homelab-network"
driver = "bridge"
attachable = true
subnet = "10.100.0.0/16"
}
module "actualbudget" {
source = "${local.module_dir}/20-services-apps/actualbudget"
volume_path = "${local.data_dir}/actual"
networks = [module.homelab_docker_network.name]
}
module "emulatorjs" {
source = "${local.module_dir}/20-services-apps/emulatorjs"
volume_path = "${local.data_dir}/emulatorjs"
}

15
services/outputs.tf Normal file
View File

@@ -0,0 +1,15 @@
// Services environment outputs
// Consolidated service definitions for networking
output "service_definitions" {
description = "Service definitions for all services"
value = [
module.actualbudget.service_definition,
module.emulatorjs.service_definition
]
}
output "homelab_docker_network_name" {
description = "The name of the Docker network"
value = module.homelab_docker_network.name
}

View File

@@ -1,26 +0,0 @@
# Example terraform.tfvars file
# Copy to terraform.tfvars and fill with your values
# Generic
timezone = "Australia/Sydney"
puid = 1000
pgid = 1000
data_dir = "/srv/docker_data"
# Watchtower
watchtower_enable_notifications = false
# watchtower_notification_url = "discord://token@webhookId"
# EmulatorJS
emulatorjs_frontend_port = 5823
emulatorjs_config_port = 5824
emulatorjs_backend_port = 5825
# ActualBudget
actualbudget_port = 5006
# Cloudflare
cloudflare_api_token = "your-cloudflare-api-token" # API token with required permissions
cloudflare_account_id = "your-cloudflare-account-id" # Found in Cloudflare dashboard URL
cloudflare_zone_id = "your-cloudflare-zone-id" # Found in the domain overview page
domain = "yourdomain.com" # Your domain on Cloudflare

View File

@@ -1,79 +0,0 @@
// Generic
variable "timezone" {
description = "Timezone for the system"
type = string
}
variable "puid" {
description = "User ID for the container"
type = number
}
variable "pgid" {
description = "Group ID for the container"
type = number
}
variable "data_dir" {
description = "Base directory for data volumes"
type = string
}
// Watchtower
variable "watchtower_enable_notifications" {
description = "Enable Watchtower update notifications"
type = bool
default = false
}
variable "watchtower_notification_url" {
description = "Webhook URL for Watchtower notifications (Discord, Slack, etc.)"
type = string
sensitive = true // This flags the variable as sensitive in logs and outputs
default = ""
}
// EmulatorJS
variable "emulatorjs_frontend_port" {
description = "External port for the EmulatorJS frontend"
type = number
}
variable "emulatorjs_config_port" {
description = "External port for the EmulatorJS configuration interface"
type = number
}
variable "emulatorjs_backend_port" {
description = "External port for the EmulatorJS backend"
type = number
}
// ActualBudget
variable "actualbudget_port" {
description = "External port for the ActualBudget server"
type = number
}
// Cloudflare
variable "cloudflare_api_token" {
description = "API token for Cloudflare with tunnel, DNS, and zone management permissions"
type = string
sensitive = true
}
variable "cloudflare_account_id" {
description = "Cloudflare account ID"
type = string
}
variable "cloudflare_zone_id" {
description = "Cloudflare zone ID for your domain"
type = string
}
variable "domain" {
description = "Base domain name (e.g., example.com)"
type = string
}

View File