From cac26957a86d1c09553c0f004abf5e6ecc37ddc2 Mon Sep 17 00:00:00 2001 From: Yuris Cakranegara Date: Fri, 6 Jun 2025 12:01:54 +1000 Subject: [PATCH] first commit --- .gitignore | 9 + .terraform.lock.hcl | 64 +++++++ README.md | 163 ++++++++++++++++ environments/README.md | 12 ++ environments/core/main.tf | 17 ++ environments/core/variables.tf | 19 ++ environments/network/main.tf | 45 +++++ environments/network/outputs.tf | 36 ++++ environments/network/variables.tf | 21 ++ environments/services/main.tf | 36 ++++ environments/services/outputs.tf | 38 ++++ environments/services/variables.tf | 50 +++++ main.tf | 49 +++++ modules/00-globals/cloudflare/outputs.tf | 20 ++ modules/00-globals/cloudflare/variables.tf | 20 ++ .../cloudflared-tunnel/README.md | 123 ++++++++++++ .../01-networking/cloudflared-tunnel/main.tf | 95 +++++++++ .../cloudflared-tunnel/outputs.tf | 47 +++++ .../cloudflared-tunnel/variables.tf | 57 ++++++ .../01-networking/docker-network/README.md | 47 +++++ modules/01-networking/docker-network/main.tf | 31 +++ .../01-networking/docker-network/outputs.tf | 21 ++ .../01-networking/docker-network/variables.tf | 64 +++++++ .../docker-service/README.md | 89 +++++++++ .../docker-service/main.tf | 133 +++++++++++++ .../docker-service/outputs.tf | 24 +++ .../docker-service/variables.tf | 181 ++++++++++++++++++ modules/20-services-apps/actualbudget/main.tf | 49 +++++ .../20-services-apps/actualbudget/outputs.tf | 24 +++ .../actualbudget/variables.tf | 52 +++++ modules/20-services-apps/emulatorjs/main.tf | 70 +++++++ .../20-services-apps/emulatorjs/outputs.tf | 26 +++ .../20-services-apps/emulatorjs/variables.tf | 91 +++++++++ modules/20-services-apps/watchtower/README.md | 79 ++++++++ modules/20-services-apps/watchtower/main.tf | 58 ++++++ .../20-services-apps/watchtower/outputs.tf | 14 ++ .../20-services-apps/watchtower/variables.tf | 103 ++++++++++ outputs.tf | 33 ++++ providers.tf | 20 ++ terraform.tfvars.example | 26 +++ variables.tf | 79 ++++++++ versions.tf | 0 42 files changed, 2235 insertions(+) create mode 100644 .gitignore create mode 100644 .terraform.lock.hcl create mode 100644 README.md create mode 100644 environments/README.md create mode 100644 environments/core/main.tf create mode 100644 environments/core/variables.tf create mode 100644 environments/network/main.tf create mode 100644 environments/network/outputs.tf create mode 100644 environments/network/variables.tf create mode 100644 environments/services/main.tf create mode 100644 environments/services/outputs.tf create mode 100644 environments/services/variables.tf create mode 100644 main.tf create mode 100644 modules/00-globals/cloudflare/outputs.tf create mode 100644 modules/00-globals/cloudflare/variables.tf create mode 100644 modules/01-networking/cloudflared-tunnel/README.md create mode 100644 modules/01-networking/cloudflared-tunnel/main.tf create mode 100644 modules/01-networking/cloudflared-tunnel/outputs.tf create mode 100644 modules/01-networking/cloudflared-tunnel/variables.tf create mode 100644 modules/01-networking/docker-network/README.md create mode 100644 modules/01-networking/docker-network/main.tf create mode 100644 modules/01-networking/docker-network/outputs.tf create mode 100644 modules/01-networking/docker-network/variables.tf create mode 100644 modules/10-services-generic/docker-service/README.md create mode 100644 modules/10-services-generic/docker-service/main.tf create mode 100644 modules/10-services-generic/docker-service/outputs.tf create mode 100644 modules/10-services-generic/docker-service/variables.tf create mode 100644 modules/20-services-apps/actualbudget/main.tf create mode 100644 modules/20-services-apps/actualbudget/outputs.tf create mode 100644 modules/20-services-apps/actualbudget/variables.tf create mode 100644 modules/20-services-apps/emulatorjs/main.tf create mode 100644 modules/20-services-apps/emulatorjs/outputs.tf create mode 100644 modules/20-services-apps/emulatorjs/variables.tf create mode 100644 modules/20-services-apps/watchtower/README.md create mode 100644 modules/20-services-apps/watchtower/main.tf create mode 100644 modules/20-services-apps/watchtower/outputs.tf create mode 100644 modules/20-services-apps/watchtower/variables.tf create mode 100644 outputs.tf create mode 100644 providers.tf create mode 100644 terraform.tfvars.example create mode 100644 variables.tf create mode 100644 versions.tf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a068d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.terraform/ +*.tfstate +*.tfstate.* +crash.log +*.tfvars +override.tf +override.tf.json +.terraformrc +terraform.rc diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl new file mode 100644 index 0000000..6ddc078 --- /dev/null +++ b/.terraform.lock.hcl @@ -0,0 +1,64 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/cloudflare/cloudflare" { + version = "4.52.0" + constraints = "~> 4.0" + hashes = [ + "h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=", + "zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0", + "zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8", + "zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238", + "zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f", + "zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f", + "zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8", + "zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9", + "zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6", + "zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb", + "zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b", + "zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380", + "zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7", + "zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672", + ] +} + +provider "registry.opentofu.org/hashicorp/random" { + version = "3.5.1" + constraints = "~> 3.5.1" + hashes = [ + "h1:tW+G7lgqbHUtraKHPWuotYHlME1vcAf50YvOeHQlGHg=", + "zh:0002dd4c79453da5bf1bb9c52172a25d042a571f6df131b7c9ced3d1f8f3eb44", + "zh:49b0f8c2bd5632799aa6113e0e46acaa7d008f927665a41a1f8e8559fe6d8165", + "zh:56df70fca236caa06d0e636c41ab71dd1ced05375f4ddcb905b0ed2105737048", + "zh:58e4de40540c86b9e2e2595dac1318ba057718961a467fa9727866f747693eb2", + "zh:5992f11c738812ccd7476d4c607cb8b76dea5aa612be491150c89957ec395ddd", + "zh:7ff4f0b7707b51737f684e96d85a47f0dd8be0f72a3c27b0798755d3faad15e2", + "zh:8e4b0972e216c9773ab525accfa36eb27c44c751b06b125ecc53f4226c91cea8", + "zh:d8956cc5abcd5d1173b6cc25d5d8ed2c5cc456edab2fddb774a17d45e84820cb", + "zh:df7f9eb93a832e66bc20cc41c57d38954f87671ec60be09fa866273adb8d9353", + "zh:eb583d8f03b11f0b6c535375d8ed0d29e5f7f537b5c78943856d2e8ce76482d9", + ] +} + +provider "registry.opentofu.org/kreuzwerker/docker" { + version = "3.6.0" + constraints = ">= 3.0.0, ~> 3.6.0" + hashes = [ + "h1:srHf1w7rgfYAis9tzEm9fJ6DNEpzTLDJxmJBk3yvEdo=", + "zh:1b930ce8aeeb562529383079a146ef6d8aa293a871490cabaf62cd07351f594a", + "zh:2b6285f977c5c2f43bd4640e634d3b74d32d7adf377824773e1ebf27b18d43c2", + "zh:31601af6e77cddc6c9dc9cdbcb53a18e38f35bcf4108ad4c88eb4b446b1a8100", + "zh:58b10d6923e89ac8bd7067760d5d115570ed29bd3b81c931f5ae09e2c5228d10", + "zh:62b76085922cb78370777c10286021104dde7ac7550f97bcbd5611cd7675db74", + "zh:76cacedffea6e07c35d649b79ad2a72dfe8f0c9d02c765a24162f3ebfef1f54d", + "zh:794d753e99319b42b746ba9405297049bb81b6e2d7c12496457bdf73e7d7cebe", + "zh:a8140eed5f3cf57aae4586471cf79a19babda57db9e4a29bd783e8b1fc12a2c0", + "zh:c62928bcdbf2965fd37d7b8fcb58b1e2bbecd9a393e579ac009439dbac3861cd", + "zh:c9b061d4b779cd9f833648a5220f35f027152e4ec499b7808c0e9b613dd70a87", + "zh:ce6418f00a732c9b1e2a1d250675820f7e2112923eea14e08b9dc82d187f8ea2", + "zh:df7f80e4f07d627ca2bb5586c62986e543ee89fab4c83105a8d485c3ec97007d", + "zh:e48a2416a48e22f5db90500791bc8deb72e110e141df685fe0e15266695629a9", + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d7b484 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# My OpenTofu Homelab Infrastructure + +This project uses [OpenTofu](https://opentofu.org/) to manage the infrastructure for my personal homelab. It's designed to be modular and evolve, initially focusing on deploying Dockerized applications on a Debian server ("casa") and later expanding to include Proxmox VE for virtualization. + +**Current Time:** June 5, 2025 + +## Table of Contents + +1. [Overview](#overview) +2. [Prerequisites](#prerequisites) +3. [Project Structure](#project-structure) +4. [Configuration](#configuration) +5. [Usage](#usage) +6. [Module Overview](#module-overview) +7. [Future Plans](#future-plans) + +## Overview + +This OpenTofu configuration manages various self-hosted services primarily as Docker containers. The goals are: + +* **Reproducibility:** Easily set up or replicate the homelab environment. +* **Version Control:** Track all infrastructure changes using Git. +* **Automation:** Automate the provisioning and management of services. +* **Modularity:** Organize infrastructure into reusable and understandable components. + +## Prerequisites + +Before you begin, ensure you have the following installed and configured: + +* **OpenTofu:** Version `1.6.0` or higher (see `versions.tf`). [Installation Guide](https://opentofu.org/docs/intro/install/) +* **Git:** For version control. +* **Docker:** Installed and running on the target host(s) (initially "casa", later on VMs within Proxmox). +* **(Optional) Cloudflare Account:** If using the Cloudflare provider for DNS management or Tunnels. You'll need your Zone ID and an API Token. +* **(Future) Proxmox VE:** When moving to the virtualization phase, a Proxmox VE host will be required. +* **(Optional) Tailscale:** For secure remote access. + +## Project Structure + +The project is organized as follows: + +``` +homelab/ +├── .gitignore # Files and directories to ignore +├── README.md # This file +│ +├── main.tf # Root module: orchestrates module calls +├── variables.tf # Root module: global input variables +├── outputs.tf # Root module: global outputs +├── providers.tf # Root module: provider configurations +├── versions.tf # Root module: OpenTofu & provider version constraints +├── terraform.tfvars.example # Example variables file +│ +├── environments/ +│ ├── core/ # Core infrastructure (monitoring, globals) +│ ├── network/ # Network infrastructure (Docker networks, Cloudflare) +│ └── services/ # Application services (Docker containers) +│ +└── modules/ # Local modules for different components + ├── 00-globals/ # Optional: Global data sources/locals + ├── 01-networking/ + │ ├── docker-network/ + │ ├── cloudflare-dns-record/ + │ └── cloudflared-tunnel/ + ├── 02-compute/ # Future: Proxmox VM/LXC modules + │ └── proxmox-vm/ + ├── 10-services-generic/ + │ └── docker-service/ # Generic module for deploying Docker containers + └── 20-services-apps/ # Application-specific wrapper modules + ├── jellyfin/ + ├── affine/ + └── ... # Other application modules +``` + +## Configuration + +1. **Clone the repository:** + ```bash + git clone https://github.com/yurisasc/homelab.git + cd homelab + ``` + +2. **Provider Configuration:** + Review `providers.tf` and ensure provider configurations are suitable. For providers requiring authentication (like Cloudflare or Proxmox later), API tokens and other sensitive data should be supplied via variables. + +3. **Create a `terraform.tfvars` file:** + Copy `terraform.tfvars.example` to `terraform.tfvars`: + ```bash + cp terraform.tfvars.example terraform.tfvars + ``` + **Edit `terraform.tfvars` to set your specific values.** This file is included in `.gitignore` by default if it's expected to contain secrets. + Common variables to configure might include: + * `cloudflare_api_token` + * `cloudflare_zone_id` + * `docker_host_data_path_base` (e.g., `/srv/docker_data` on "casa") + * `domain_name` (e.g., `homelab.yourdomain.com`) + * Specific application settings (ports, image tags, paths for persistent data). + * (Future) Proxmox API details. + +## Usage + +Make sure you are in the root directory of the project (`homelab/`). + +1. **Initialize OpenTofu:** + This downloads the necessary provider plugins. Run this once when you first set up the project or when you add/change providers or modules. + ```bash + tofu init + ``` + +2. **Plan Changes:** + This command shows you what OpenTofu will do to reach the desired state defined in your configuration files. Review the plan carefully. + ```bash + tofu plan + # To use a specific .tfvars file: + # tofu plan -var-file="terraform.tfvars" + ``` + +3. **Apply Changes:** + This command applies the changes outlined in the plan. You will be prompted for confirmation. + ```bash + tofu apply + # To use a specific .tfvars file: + # tofu apply -var-file="terraform.tfvars" + # To auto-approve (use with caution): + # tofu apply -auto-approve -var-file="terraform.tfvars" + ``` + +4. **View Outputs:** + If you have defined outputs in `outputs.tf` or in your modules, you can view them: + ```bash + tofu output + ``` + +5. **Destroy Infrastructure (Use with Extreme Caution!):** + This command will attempt to destroy all resources managed by this OpenTofu configuration. + ```bash + tofu destroy + # To use a specific .tfvars file: + # tofu destroy -var-file="terraform.tfvars" + ``` + +## Module Overview + +This project aims for a high degree of modularity: + +* **`modules/01-networking/`**: Contains modules for creating Docker networks, managing Cloudflare DNS records, and deploying `cloudflared` tunnels. +* **`modules/10-services-generic/docker-service/`**: A reusable module to deploy any generic Docker container with common configurations (ports, volumes, environment variables, etc.). +* **`modules/20-services-apps/`**: Contains "wrapper" modules for specific applications (e.g., Jellyfin, Affine, Nginx Proxy Manager). These modules typically call the generic `docker-service` module with pre-filled defaults and simpler inputs specific to that application. +* **`modules/02-compute/`**: (Planned for future Proxmox integration) Will contain modules for provisioning Virtual Machines or LXC containers on Proxmox VE, which can then serve as hosts for Docker or other services. + +Each module should have its own `README.md` (eventually) detailing its purpose, inputs, and outputs. + +## Future Plans + +1. **Phase 1 (Current):** Codify all existing Dockerized services running on the primary Debian server ("casa") using OpenTofu. +2. **Phase 2:** Set up a new machine with Proxmox VE. +3. **Phase 3:** Adapt and expand these OpenTofu configurations to: + * Provision VMs on Proxmox (e.g., a dedicated Docker host VM). + * Deploy the Dockerized services (from Phase 1) inside these VMs using OpenTofu. + * Potentially manage LXC containers on Proxmox for suitable services. + +--- + +Remember to replace placeholders like `` and customize the content based on the specifics of your setup as it evolves. \ No newline at end of file diff --git a/environments/README.md b/environments/README.md new file mode 100644 index 0000000..aba5ba9 --- /dev/null +++ b/environments/README.md @@ -0,0 +1,12 @@ +# 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 +``` \ No newline at end of file diff --git a/environments/core/main.tf b/environments/core/main.tf new file mode 100644 index 0000000..3165631 --- /dev/null +++ b/environments/core/main.tf @@ -0,0 +1,17 @@ +// 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 +} diff --git a/environments/core/variables.tf b/environments/core/variables.tf new file mode 100644 index 0000000..2bf4cd4 --- /dev/null +++ b/environments/core/variables.tf @@ -0,0 +1,19 @@ +// 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 = "" +} diff --git a/environments/network/main.tf b/environments/network/main.tf new file mode 100644 index 0000000..a80858a --- /dev/null +++ b/environments/network/main.tf @@ -0,0 +1,45 @@ +// 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 +} diff --git a/environments/network/outputs.tf b/environments/network/outputs.tf new file mode 100644 index 0000000..beeb5e0 --- /dev/null +++ b/environments/network/outputs.tf @@ -0,0 +1,36 @@ +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 +} diff --git a/environments/network/variables.tf b/environments/network/variables.tf new file mode 100644 index 0000000..b0fc7a7 --- /dev/null +++ b/environments/network/variables.tf @@ -0,0 +1,21 @@ + +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 +} diff --git a/environments/services/main.tf b/environments/services/main.tf new file mode 100644 index 0000000..80f76dd --- /dev/null +++ b/environments/services/main.tf @@ -0,0 +1,36 @@ +// 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 +} diff --git a/environments/services/outputs.tf b/environments/services/outputs.tf new file mode 100644 index 0000000..e44b79b --- /dev/null +++ b/environments/services/outputs.tf @@ -0,0 +1,38 @@ +// 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 +} diff --git a/environments/services/variables.tf b/environments/services/variables.tf new file mode 100644 index 0000000..d235554 --- /dev/null +++ b/environments/services/variables.tf @@ -0,0 +1,50 @@ +// 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 +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..b1910a9 --- /dev/null +++ b/main.tf @@ -0,0 +1,49 @@ +// Root module that orchestrates all environments +// This unified approach keeps a single entry point while organizing by function + +// 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 "core" { + 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 +module "services" { + source = "./environments/services" + + depends_on = [module.core, module.network] + + timezone = var.timezone + puid = var.puid + pgid = var.pgid + data_dir = var.data_dir + + // ActualBudget variables + actualbudget_port = var.actualbudget_port + + // EmulatorJS variables + 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] +} diff --git a/modules/00-globals/cloudflare/outputs.tf b/modules/00-globals/cloudflare/outputs.tf new file mode 100644 index 0000000..ccf0b4f --- /dev/null +++ b/modules/00-globals/cloudflare/outputs.tf @@ -0,0 +1,20 @@ +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 +} diff --git a/modules/00-globals/cloudflare/variables.tf b/modules/00-globals/cloudflare/variables.tf new file mode 100644 index 0000000..cc9d7e2 --- /dev/null +++ b/modules/00-globals/cloudflare/variables.tf @@ -0,0 +1,20 @@ +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 +} \ No newline at end of file diff --git a/modules/01-networking/cloudflared-tunnel/README.md b/modules/01-networking/cloudflared-tunnel/README.md new file mode 100644 index 0000000..69dd5b7 --- /dev/null +++ b/modules/01-networking/cloudflared-tunnel/README.md @@ -0,0 +1,123 @@ +# Cloudflare Tunnel Module + +This module creates and manages Cloudflare Tunnels using OpenTofu, automating the entire setup process including: + +1. Creating the Cloudflare tunnel +2. Configuring tunnel routing rules +3. Setting up DNS records +4. Deploying the cloudflared tunnel container + +## Features + +- **Automated Tunnel Management**: Creates and configures Cloudflare tunnels via the API +- **Multiple Service Support**: Route multiple applications through a single tunnel +- **DNS Management**: Automatically creates DNS records for your applications +- **Docker Integration**: Deploys the cloudflared container with proper configuration +- **Secret Management**: Auto-generates tunnel secrets if not provided + +## Prerequisites + +Before using this module, you need: + +1. A Cloudflare account +2. API token with the following permissions: + - Account.Cloudflare Tunnel:Edit + - Zone.DNS:Edit + - Zone.Zone:Read +3. Your Cloudflare account ID and zone ID + +## Usage + +```hcl +module "homelab_tunnel" { + source = "./modules/01-networking/cloudflared-tunnel" + + cloudflare_account_id = var.cloudflare_account_id + cloudflare_zone_id = var.cloudflare_zone_id + + tunnel_name = "homelab-tunnel" + container_name = "cloudflared-homelab" + + ingress_rules = [ + { + hostname = "budget.example.com" + service = "http://actualbudget:5006" + }, + { + hostname = "dashboard.example.com" + service = "http://homepage:3000" + } + ] +} +``` + +## Connecting with the Cloudflare Globals Module + +For cleaner code organization, use the globals module: + +```hcl +module "cloudflare_globals" { + source = "./modules/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 = "example.com" +} + +module "homelab_tunnel" { + source = "./modules/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-tunnel" + + ingress_rules = [ + { + hostname = "budget.${module.cloudflare_globals.domain}" + service = "http://actualbudget:5006" + } + ] +} +``` + +## Variables + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `cloudflare_account_id` | Cloudflare account ID | string | (required) | +| `cloudflare_zone_id` | Cloudflare zone ID for your domain | string | (required) | +| `container_name` | Name of the Cloudflare tunnel container | string | "" (defaults to "cloudflared-{tunnel_name}") | +| `image_tag` | Docker image tag for cloudflared | string | "latest" | +| `tunnel_name` | Name of the tunnel | string | (required) | +| `tunnel_secret` | Secret for the tunnel | string | "" (auto-generated if empty) | +| `ingress_rules` | List of ingress rules | list(object) | (required) | +| `monitoring` | Enable monitoring via Watchtower | bool | true | + +### Ingress Rules Object Structure + +```hcl +ingress_rules = [ + { + hostname = "app.example.com" # FQDN for the service + service = "http://container:port" # Internal service URL + path = "/api/*" # Optional path pattern + create_dns_record = true # Whether to create DNS record (default: true) + } +] +``` + +## Outputs + +| Name | Description | +|------|-------------| +| `tunnel_id` | ID of the created tunnel | +| `tunnel_name` | Name of the tunnel | +| `tunnel_token` | Token for the tunnel (sensitive) | +| `cname_target` | CNAME target for the tunnel | +| `dns_records` | Map of created DNS records | +| `container_name` | Name of the cloudflared container | +| `container_id` | ID of the cloudflared container | +| `image_id` | ID of the cloudflared image | +| `ip_address` | IP address of the cloudflared container | diff --git a/modules/01-networking/cloudflared-tunnel/main.tf b/modules/01-networking/cloudflared-tunnel/main.tf new file mode 100644 index 0000000..be48442 --- /dev/null +++ b/modules/01-networking/cloudflared-tunnel/main.tf @@ -0,0 +1,95 @@ +// Cloudflare Tunnel module +// This module creates a Cloudflare tunnel and deploys a cloudflared container + +terraform { + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.5.1" + } + } +} + +// Generate a random secret for the tunnel if none provided +resource "random_id" "tunnel_secret" { + count = var.tunnel_secret == "" ? 1 : 0 + byte_length = 35 +} + +// Create the Cloudflare Tunnel +resource "cloudflare_zero_trust_tunnel_cloudflared" "this" { + account_id = var.cloudflare_account_id + name = var.tunnel_name + secret = var.tunnel_secret != "" ? var.tunnel_secret : random_id.tunnel_secret[0].b64_std +} + +locals { + all_ingress_rules = [for rule in var.ingress_rules : rule if rule != null] +} + +// Configure tunnel routing +resource "cloudflare_zero_trust_tunnel_cloudflared_config" "this" { + account_id = var.cloudflare_account_id + tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.this.id + + config { + // Add all service ingress rules + dynamic "ingress_rule" { + for_each = local.all_ingress_rules + content { + hostname = ingress_rule.value.hostname + service = ingress_rule.value.service + } + } + + // Default catch-all rule (required) + ingress_rule { + service = "http_status:404" + } + } +} + +// Create DNS record for each service +resource "cloudflare_record" "service" { + for_each = { for rule in var.ingress_rules : rule.hostname => rule } + + zone_id = var.cloudflare_zone_id + name = split(".", each.value.hostname)[0] // Extract subdomain + content = "${cloudflare_zero_trust_tunnel_cloudflared.this.id}.cfargotunnel.com" + type = "CNAME" + proxied = true +} + +// Set up the Docker container +locals { + container_name = var.container_name != "" ? var.container_name : "cloudflared-${var.tunnel_name}" + image_tag = var.image_tag != "" ? var.image_tag : "latest" +} + +module "cloudflared" { + source = "../../10-services-generic/docker-service" + + container_name = var.container_name + image = "cloudflare/cloudflared" + tag = local.image_tag + + // Environment variables with tunnel token + env_vars = { + TUNNEL_TOKEN = cloudflare_zero_trust_tunnel_cloudflared.this.tunnel_token + } + + // Command to run tunnel + command = ["tunnel", "--no-autoupdate", "run"] + + // Restart policy + restart_policy = "unless-stopped" + + // Enable monitoring for the container via Watchtower if specified + monitoring = var.monitoring + + networks = var.networks +} diff --git a/modules/01-networking/cloudflared-tunnel/outputs.tf b/modules/01-networking/cloudflared-tunnel/outputs.tf new file mode 100644 index 0000000..307a0c4 --- /dev/null +++ b/modules/01-networking/cloudflared-tunnel/outputs.tf @@ -0,0 +1,47 @@ +// Outputs for the Cloudflare tunnel module + +output "tunnel_id" { + description = "ID of the created Cloudflare tunnel" + value = cloudflare_zero_trust_tunnel_cloudflared.this.id +} + +output "tunnel_name" { + description = "Name of the Cloudflare tunnel" + value = cloudflare_zero_trust_tunnel_cloudflared.this.name +} + +output "tunnel_token" { + description = "Token for the Cloudflare tunnel" + value = cloudflare_zero_trust_tunnel_cloudflared.this.tunnel_token + sensitive = true +} + +output "cname_target" { + description = "CNAME target for the tunnel" + 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" { + description = "The name of the Cloudflared tunnel container" + value = module.cloudflared.container_name +} + +output "container_id" { + description = "The ID of the Cloudflared tunnel container" + value = module.cloudflared.container_id +} + +output "image_id" { + description = "The ID of the Cloudflared image" + value = module.cloudflared.image_id +} + +output "ip_address" { + description = "The IP address of the Cloudflared container" + value = module.cloudflared.ip_address +} diff --git a/modules/01-networking/cloudflared-tunnel/variables.tf b/modules/01-networking/cloudflared-tunnel/variables.tf new file mode 100644 index 0000000..c2a3b35 --- /dev/null +++ b/modules/01-networking/cloudflared-tunnel/variables.tf @@ -0,0 +1,57 @@ +// Variables for the Cloudflare tunnel module + +variable "cloudflare_account_id" { + description = "Cloudflare account ID" + type = string +} + +variable "cloudflare_zone_id" { + description = "Cloudflare zone ID for your domain" + type = string +} + +variable "container_name" { + description = "Name of the Cloudflare tunnel container" + type = string + default = "" +} + +variable "image_tag" { + description = "Docker image tag for cloudflare/cloudflared" + type = string + default = "latest" +} + +variable "tunnel_name" { + description = "Name of the Cloudflare tunnel" + type = string +} + +variable "tunnel_secret" { + description = "Secret for the Cloudflare tunnel (will be auto-generated if empty)" + type = string + sensitive = true + default = "" +} + +variable "ingress_rules" { + description = "List of ingress rules for services to be exposed through the tunnel" + type = list(object({ + hostname = string + service = string + })) + default = [] +} + +variable "monitoring" { + description = "Enable monitoring via Watchtower" + type = bool + default = true +} + +variable "networks" { + description = "List of networks to connect the container to" + type = list(string) + default = [] +} + diff --git a/modules/01-networking/docker-network/README.md b/modules/01-networking/docker-network/README.md new file mode 100644 index 0000000..6a2c71a --- /dev/null +++ b/modules/01-networking/docker-network/README.md @@ -0,0 +1,47 @@ +# Docker Network Module + +This module creates a Docker network that allows containers to communicate with each other using container names as hostnames. + +## Purpose + +The module is designed to create a consistent Docker network for all homelab services, enabling direct container-to-container communication using container names instead of IP addresses. + +## Usage + +```hcl +module "homelab_network" { + source = "../modules/01-networking/docker-network" + + network_name = "homelab-network" + driver = "bridge" + + # Optional: Configure specific subnet (uncomment if needed) + # subnet = "172.20.0.0/16" + # gateway = "172.20.0.1" +} +``` + +## Input Variables + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|----------| +| `network_name` | Name of the Docker network | `string` | N/A | Yes | +| `driver` | Network driver to use | `string` | `"bridge"` | No | +| `internal` | Restrict external access if true | `bool` | `false` | No | +| `attachable` | Enable manual container attachment | `bool` | `true` | No | +| `ipam_driver` | IP address management driver | `string` | `"default"` | No | +| `subnet` | Subnet in CIDR format | `string` | `""` | No | +| `gateway` | Gateway IP for the subnet | `string` | `""` | No | +| `ip_range` | Range for container IP allocation | `string` | `""` | No | +| `aux_address` | Auxiliary addresses for driver | `map(string)` | `{}` | No | +| `labels` | Docker labels to add to the network | `map(string)` | `{}` | No | +| `options` | Driver-specific options | `map(string)` | `{}` | No | + +## Outputs + +| Name | Description | +|------|-------------| +| `network_id` | The ID of the created Docker network | +| `network_name` | The name of the Docker network | +| `network_driver` | The driver of the Docker network | +| `ipam_config` | The IPAM configuration of the network | diff --git a/modules/01-networking/docker-network/main.tf b/modules/01-networking/docker-network/main.tf new file mode 100644 index 0000000..00a7a93 --- /dev/null +++ b/modules/01-networking/docker-network/main.tf @@ -0,0 +1,31 @@ +// Docker Network Module +// This module creates a Docker network for container communication + +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.6.0" + } + } +} + +resource "docker_network" "this" { + name = var.name + driver = var.driver + internal = var.internal + attachable = var.attachable + ipam_driver = var.ipam_driver + + dynamic "ipam_config" { + for_each = var.subnet != "" ? [1] : [] + content { + subnet = var.subnet + gateway = var.gateway + ip_range = var.ip_range + aux_address = var.aux_address + } + } + + options = var.options +} diff --git a/modules/01-networking/docker-network/outputs.tf b/modules/01-networking/docker-network/outputs.tf new file mode 100644 index 0000000..4f1b10f --- /dev/null +++ b/modules/01-networking/docker-network/outputs.tf @@ -0,0 +1,21 @@ +// Outputs for Docker Network module + +output "network_id" { + description = "The ID of the Docker network" + value = docker_network.this.id +} + +output "name" { + description = "The name of the Docker network" + value = docker_network.this.name +} + +output "network_driver" { + description = "The driver of the Docker network" + value = docker_network.this.driver +} + +output "ipam_config" { + description = "The IPAM configuration of the Docker network" + value = docker_network.this.ipam_config +} diff --git a/modules/01-networking/docker-network/variables.tf b/modules/01-networking/docker-network/variables.tf new file mode 100644 index 0000000..7c615fb --- /dev/null +++ b/modules/01-networking/docker-network/variables.tf @@ -0,0 +1,64 @@ +variable "name" { + description = "Name of the Docker network" + type = string +} + +variable "driver" { + description = "Name of the network driver to use" + type = string + default = "bridge" +} + +variable "internal" { + description = "Restrict external access to the network if true" + type = bool + default = false +} + +variable "attachable" { + description = "Enable manual container attachment if true" + type = bool + default = true +} + +variable "ipam_driver" { + description = "Driver used for IP address management" + type = string + default = "default" +} + +variable "subnet" { + description = "Subnet in CIDR format that represents a network segment" + type = string + default = "" +} + +variable "gateway" { + description = "IPv4 or IPv6 gateway for the subnet" + type = string + default = "" +} + +variable "ip_range" { + description = "Range of IPs from which to allocate container IPs" + type = string + default = "" +} + +variable "aux_address" { + description = "Auxiliary IPv4 or IPv6 addresses used by the driver" + type = map(string) + default = {} +} + +variable "labels" { + description = "Labels to add to the network" + type = map(string) + default = {} +} + +variable "options" { + description = "Network driver specific options" + type = map(string) + default = {} +} diff --git a/modules/10-services-generic/docker-service/README.md b/modules/10-services-generic/docker-service/README.md new file mode 100644 index 0000000..f1547dc --- /dev/null +++ b/modules/10-services-generic/docker-service/README.md @@ -0,0 +1,89 @@ +# Generic Docker Service Module + +This is a reusable OpenTofu module for deploying Docker containers with configurable options. It serves as the foundation for specific application modules in this homelab project. + +## Features + +- Pull and manage Docker images +- Configure container networking, ports, and volumes +- Set environment variables and labels +- Configure resource limits and constraints +- Set up health checks +- Support for container logging options + +## Usage + +This module is typically called by application-specific modules rather than used directly, but can be used as follows: + +```hcl +module "my_service" { + source = "../../10-services-generic/docker-service" + + container_name = "my-service" + image = "organization/image" + tag = "latest" + + restart_policy = "unless-stopped" + network_mode = "bridge" + + // Port mappings + ports = [ + { + internal = 8080 + external = 8080 + protocol = "tcp" + } + ] + + // Volume mappings + volumes = [ + { + host_path = "/path/on/host" + container_path = "/path/in/container" + read_only = false + } + ] + + // Environment variables + env_vars = { + VARIABLE_NAME = "value" + } + + // Container labels + labels = { + "com.example.description" = "My service description" + } + + // Enable Watchtower updates + monitoring = true +} +``` + +## Required Providers + +This module requires the Docker provider to be configured in your root module: + +```hcl +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + version = ">= 3.0.0" + } + } +} +``` + +## Inputs + +See the `variables.tf` file for a complete list of input variables and their descriptions. + +## Outputs + +| Name | Description | +|------|-------------| +| container_name | Name of the Docker container | +| container_id | ID of the Docker container | +| image_id | ID of the Docker image | +| ip_address | IP address of the container (if applicable) | +| container_ports | Published ports of the container | diff --git a/modules/10-services-generic/docker-service/main.tf b/modules/10-services-generic/docker-service/main.tf new file mode 100644 index 0000000..0fc3d14 --- /dev/null +++ b/modules/10-services-generic/docker-service/main.tf @@ -0,0 +1,133 @@ +// Generic Docker service module +// Creates and manages a Docker container with configurable options + +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + version = ">= 3.0.0" + } + } +} + +locals { + network_mode = var.network_mode + container_name = var.container_name + image_name = "${var.image}:${var.tag}" + + // Prepare ports configuration + ports_config = [ + for port in var.ports : { + internal = port.internal + external = port.external + protocol = port.protocol + } + ] + + // Prepare volumes configuration + volumes_config = [ + for volume in var.volumes : { + host_path = volume.host_path + container_path = volume.container_path + read_only = volume.read_only + } + ] + + // Define monitoring labels if enabled + monitoring_labels = var.monitoring ? { + "com.centurylinklabs.watchtower.enable" = "true" + } : {} + + // Merge provided labels with monitoring labels + merged_labels = merge(var.labels, local.monitoring_labels) +} + +// Pull the Docker image +resource "docker_image" "service_image" { + name = local.image_name + keep_locally = var.keep_image_locally + pull_triggers = [var.tag] +} + +// Create the Docker container +resource "docker_container" "service_container" { + name = local.container_name + image = docker_image.service_image.image_id + + restart = var.restart_policy + + # Set the network mode (bridge, host, etc.) + network_mode = local.network_mode + + # Dynamically configure ports based on the provided list + dynamic "ports" { + for_each = local.ports_config + content { + internal = ports.value.internal + external = ports.value.external + protocol = ports.value.protocol + } + } + + # Dynamically configure networks based on the provided list + dynamic "networks_advanced" { + for_each = var.networks + content { + name = networks_advanced.value + } + } + + # Dynamically configure volumes based on the provided list + dynamic "volumes" { + for_each = local.volumes_config + content { + host_path = volumes.value.host_path + container_path = volumes.value.container_path + read_only = volumes.value.read_only + } + } + + # Configure environment variables - map to array of strings + env = [for k, v in var.env_vars : "${k}=${v}"] + + # Set container labels + dynamic "labels" { + for_each = local.merged_labels + content { + label = labels.key + value = labels.value + } + } + + # Add container healthcheck if configured + dynamic "healthcheck" { + for_each = var.healthcheck != null ? [var.healthcheck] : [] + content { + test = healthcheck.value.test + interval = healthcheck.value.interval + timeout = healthcheck.value.timeout + start_period = healthcheck.value.start_period + retries = healthcheck.value.retries + } + } + + # Set resource limits if specified + memory = var.memory_limit + memory_swap = var.memory_swap_limit + cpu_shares = var.cpu_shares + + # Other container options + dns = var.dns + dns_search = var.dns_search + hostname = var.hostname + domainname = var.domainname + user = var.user + working_dir = var.working_dir + command = var.command + entrypoint = var.entrypoint + privileged = var.privileged + + # Set log options + log_driver = var.log_driver + log_opts = var.log_opts +} diff --git a/modules/10-services-generic/docker-service/outputs.tf b/modules/10-services-generic/docker-service/outputs.tf new file mode 100644 index 0000000..aff6ac7 --- /dev/null +++ b/modules/10-services-generic/docker-service/outputs.tf @@ -0,0 +1,24 @@ +output "container_name" { + description = "Name of the Docker container" + value = docker_container.service_container.name +} + +output "container_id" { + description = "ID of the Docker container" + value = docker_container.service_container.id +} + +output "image_id" { + description = "ID of the Docker image" + value = docker_image.service_image.id +} + +output "ip_address" { + description = "IP address of the container (if applicable)" + value = docker_container.service_container.network_data != null ? docker_container.service_container.network_data[0].ip_address : null +} + +output "container_ports" { + description = "Published ports of the container" + value = docker_container.service_container.ports +} diff --git a/modules/10-services-generic/docker-service/variables.tf b/modules/10-services-generic/docker-service/variables.tf new file mode 100644 index 0000000..e5b8f86 --- /dev/null +++ b/modules/10-services-generic/docker-service/variables.tf @@ -0,0 +1,181 @@ +variable "container_name" { + description = "Name of the Docker container" + type = string +} + +variable "image" { + description = "Docker image name" + type = string +} + +variable "tag" { + description = "Docker image tag" + type = string + default = "latest" +} + +variable "keep_image_locally" { + description = "Whether to keep the Docker image locally after pulling" + type = bool + default = true +} + +variable "restart_policy" { + description = "Docker restart policy (no, always, unless-stopped, on-failure)" + type = string + default = "unless-stopped" +} + +variable "network_mode" { + description = "Docker network mode (bridge, host, etc.)" + type = string + default = "bridge" +} + +variable "ports" { + description = "List of port mappings" + type = list(object({ + internal = number + external = number + protocol = string + })) + default = [] +} + +variable "networks" { + description = "List of networks to connect the container to" + type = list(string) + default = [] +} + +variable "volumes" { + description = "List of volume mappings" + type = list(object({ + host_path = string + container_path = string + read_only = bool + })) + default = [] +} + +variable "env_vars" { + description = "Environment variables for the container" + type = map(string) + default = {} + sensitive = true +} + +variable "labels" { + description = "Docker container labels" + type = map(string) + default = {} +} + +variable "monitoring" { + description = "Enable container monitoring via Watchtower" + type = bool + default = true +} + +variable "healthcheck" { + description = "Container healthcheck configuration" + type = object({ + test = list(string) + interval = string + timeout = string + start_period = string + retries = number + }) + default = null +} + +// Resource limits +variable "memory_limit" { + description = "Memory limit for the container (in MB)" + type = number + default = null +} + +variable "memory_swap_limit" { + description = "Memory swap limit for the container (in MB)" + type = number + default = null +} + +variable "cpu_shares" { + description = "CPU shares for the container (relative weight)" + type = number + default = null +} + +// Networking options +variable "dns" { + description = "DNS servers for the container" + type = list(string) + default = null +} + +variable "dns_search" { + description = "DNS search domains for the container" + type = list(string) + default = null +} + +variable "hostname" { + description = "Container hostname" + type = string + default = null +} + +variable "domainname" { + description = "Container domainname" + type = string + default = null +} + +// Execution options +variable "user" { + description = "User to run commands as inside the container" + type = string + default = "" +} + +variable "working_dir" { + description = "Working directory inside the container" + type = string + default = null +} + +variable "command" { + description = "Command to run when starting the container" + type = list(string) + default = null +} + +variable "entrypoint" { + description = "Entrypoint for the container" + type = list(string) + default = null +} + +variable "privileged" { + description = "Run container in privileged mode" + type = bool + default = false +} + +// Logging options +variable "log_driver" { + description = "Log driver for the container" + type = string + default = "json-file" +} + +variable "log_opts" { + description = "Log driver options" + type = map(string) + default = { + max-size = "10m" + max-file = "3" + } +} diff --git a/modules/20-services-apps/actualbudget/main.tf b/modules/20-services-apps/actualbudget/main.tf new file mode 100644 index 0000000..5798774 --- /dev/null +++ b/modules/20-services-apps/actualbudget/main.tf @@ -0,0 +1,49 @@ +// ActualBudget module for budgeting +// This module configures an ActualBudget container with the specified volumes + +locals { + container_name = var.container_name != "" ? var.container_name : "actualbudget" + image_tag = var.image_tag != "" ? var.image_tag : "latest" + + default_env_vars = { + TZ = var.timezone + PUID = var.puid + PGID = var.pgid + } + + default_volumes = [ + { + container_path = "/data" + host_path = var.data_volume_path + read_only = false + } + ] +} + +module "actualbudget" { + source = "../../10-services-generic/docker-service" + + container_name = var.container_name + image = "actualbudget/actual-server" + 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 + + // Enable monitoring for the container via Watchtower + monitoring = var.monitoring + + networks = var.networks +} diff --git a/modules/20-services-apps/actualbudget/outputs.tf b/modules/20-services-apps/actualbudget/outputs.tf new file mode 100644 index 0000000..c9c03a2 --- /dev/null +++ b/modules/20-services-apps/actualbudget/outputs.tf @@ -0,0 +1,24 @@ +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}" +} diff --git a/modules/20-services-apps/actualbudget/variables.tf b/modules/20-services-apps/actualbudget/variables.tf new file mode 100644 index 0000000..517796e --- /dev/null +++ b/modules/20-services-apps/actualbudget/variables.tf @@ -0,0 +1,52 @@ +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 = [] +} \ No newline at end of file diff --git a/modules/20-services-apps/emulatorjs/main.tf b/modules/20-services-apps/emulatorjs/main.tf new file mode 100644 index 0000000..a7d9b4a --- /dev/null +++ b/modules/20-services-apps/emulatorjs/main.tf @@ -0,0 +1,70 @@ +// EmulatorJS module for retro game emulation +// This module configures an EmulatorJS container with the specified volumes + +locals { + 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 +module "emulatorjs" { + source = "../../10-services-generic/docker-service" + + container_name = local.container_name + image = "linuxserver/emulatorjs" + tag = local.image_tag + + restart_policy = var.restart_policy + network_mode = "bridge" + + env_vars = local.env_vars + volumes = local.volumes + + labels = var.labels + + // Default ports for EmulatorJS + ports = [ + { + internal = 3000 + external = var.config_port + protocol = "tcp" + }, + { + internal = 80 + external = var.frontend_port + protocol = "tcp" + }, + { + internal = 4001 + external = var.backend_port + protocol = "tcp" + } + ] + + // Enable monitoring for the container via Watchtower + monitoring = var.monitoring +} diff --git a/modules/20-services-apps/emulatorjs/outputs.tf b/modules/20-services-apps/emulatorjs/outputs.tf new file mode 100644 index 0000000..aed0377 --- /dev/null +++ b/modules/20-services-apps/emulatorjs/outputs.tf @@ -0,0 +1,26 @@ +// 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}" +} diff --git a/modules/20-services-apps/emulatorjs/variables.tf b/modules/20-services-apps/emulatorjs/variables.tf new file mode 100644 index 0000000..982aa31 --- /dev/null +++ b/modules/20-services-apps/emulatorjs/variables.tf @@ -0,0 +1,91 @@ +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 +} diff --git a/modules/20-services-apps/watchtower/README.md b/modules/20-services-apps/watchtower/README.md new file mode 100644 index 0000000..c616b7b --- /dev/null +++ b/modules/20-services-apps/watchtower/README.md @@ -0,0 +1,79 @@ +# Watchtower Module + +This module deploys a Watchtower container which automatically updates your running Docker containers when new images become available. + +## Features + +- Automatic updates for Docker containers +- Configurable update schedule +- Optional cleanup of old images +- Notification support via shoutrrr +- Container monitoring options + +## Usage + +To use this module in your root module, add the following code: + +```hcl +module "watchtower" { + source = "./modules/20-services-apps/watchtower" + + # Basic configuration + container_name = "watchtower" + image_tag = "latest" + timezone = "Australia/Sydney" + + # Update settings + poll_interval = 86400 # Check once per day (in seconds) + cleanup = true # Remove old images after updating + rolling_restart = true # Update containers one by one + + # Optional notification settings + enable_notifications = false + # notification_url = "discord://webhook-id/webhook-token" + + # Additional settings as needed + # additional_env_vars = { + # WATCHTOWER_MONITOR_ONLY = "true" + # } +} +``` + +## Required Resources + +This module leverages the generic `docker-service` module, which handles the Docker container deployment. + +## Input Variables + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| container_name | Name for the Watchtower container | string | "watchtower" | +| image_tag | The tag for the Watchtower container image | string | "latest" | +| restart_policy | Restart policy for the container | string | "unless-stopped" | +| timezone | Timezone for the container | string | "Etc/UTC" | +| cleanup | Remove old images after updating | bool | true | +| poll_interval | Poll interval (in seconds) for checking updates | number | 86400 | +| include_stopped | Include stopped containers when checking for updates | bool | false | +| revive_stopped | Restart stopped containers after updating | bool | false | +| rolling_restart | Restart containers one by one instead of all at once | bool | true | +| notification_url | URL for sending update notifications via shoutrrr | string | "" | +| enable_notifications | Enable shoutrrr notifications | bool | false | +| additional_env_vars | Additional environment variables for Watchtower | map(string) | {} | +| additional_volumes | Additional volumes to mount in the container | list(object) | [] | +| labels | Labels to set on the container | map(string) | {} | +| ports | Ports to expose (rarely needed for Watchtower) | list(object) | [] | +| monitoring | Enable monitoring for the container | bool | true | + +## Outputs + +| Name | Description | +|------|-------------| +| container_name | Name of the created Watchtower container | +| container_id | ID of the created Watchtower container | +| image_id | ID of the Watchtower image used | + +## Notes + +- Watchtower needs access to the Docker socket to monitor and update containers +- For security-conscious environments, consider limiting which containers Watchtower can update +- See the [Watchtower documentation](https://containrrr.dev/watchtower/) for more advanced configuration options diff --git a/modules/20-services-apps/watchtower/main.tf b/modules/20-services-apps/watchtower/main.tf new file mode 100644 index 0000000..f884b5b --- /dev/null +++ b/modules/20-services-apps/watchtower/main.tf @@ -0,0 +1,58 @@ +// Watchtower module for automatic Docker container updates +// This module configures a Watchtower container that monitors and updates other containers + +locals { + container_name = var.container_name != "" ? var.container_name : "watchtower" + image_tag = var.image_tag != "" ? var.image_tag : "latest" + + default_env_vars = { + TZ = var.timezone + WATCHTOWER_CLEANUP = var.cleanup + WATCHTOWER_POLL_INTERVAL = var.poll_interval + WATCHTOWER_INCLUDE_STOPPED = var.include_stopped + WATCHTOWER_REVIVE_STOPPED = var.revive_stopped + WATCHTOWER_ROLLING_RESTART = var.rolling_restart + WATCHTOWER_NOTIFICATION_URL = var.notification_url + WATCHTOWER_NOTIFICATIONS = var.enable_notifications ? "shoutrrr" : "" + } + + // Merge default env vars with any additional ones provided + 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" + container_path = "/var/run/docker.sock" + 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" { + source = "../../10-services-generic/docker-service" + + container_name = local.container_name + image = "containrrr/watchtower" + tag = local.image_tag + + restart_policy = var.restart_policy + network_mode = "bridge" + + env_vars = local.env_vars + 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 = [] +} diff --git a/modules/20-services-apps/watchtower/outputs.tf b/modules/20-services-apps/watchtower/outputs.tf new file mode 100644 index 0000000..bd424d2 --- /dev/null +++ b/modules/20-services-apps/watchtower/outputs.tf @@ -0,0 +1,14 @@ +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 +} diff --git a/modules/20-services-apps/watchtower/variables.tf b/modules/20-services-apps/watchtower/variables.tf new file mode 100644 index 0000000..f867e4a --- /dev/null +++ b/modules/20-services-apps/watchtower/variables.tf @@ -0,0 +1,103 @@ +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 +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..b94ee24 --- /dev/null +++ b/outputs.tf @@ -0,0 +1,33 @@ +// Root outputs that expose important information from each environment + +// Network environment outputs +output "cloudflare_domain" { + description = "Base domain for the homelab" + value = module.network.domain +} + +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 +} diff --git a/providers.tf b/providers.tf new file mode 100644 index 0000000..419c0d2 --- /dev/null +++ b/providers.tf @@ -0,0 +1,20 @@ +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.6.0" + } + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.5.1" + } + } +} + +provider "cloudflare" { + api_token = var.cloudflare_api_token +} diff --git a/terraform.tfvars.example b/terraform.tfvars.example new file mode 100644 index 0000000..79849a4 --- /dev/null +++ b/terraform.tfvars.example @@ -0,0 +1,26 @@ +# 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 diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..558b815 --- /dev/null +++ b/variables.tf @@ -0,0 +1,79 @@ +// 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 +} + diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..e69de29