diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl index 0703959..65bfa79 100644 --- a/.terraform.lock.hcl +++ b/.terraform.lock.hcl @@ -47,6 +47,40 @@ provider "registry.opentofu.org/germanbrew/dotenv" { ] } +provider "registry.opentofu.org/hashicorp/local" { + version = "2.5.3" + hashes = [ + "h1:mC9+u1eaUILTjxey6Ivyf/3djm//RNNze9kBVX/trng=", + "zh:32e1d4b0595cea6cda4ca256195c162772ddff25594ab4008731a2ec7be230bf", + "zh:48c390af0c87df994ec9796f04ec2582bcac581fb81ed6bb58e0671da1c17991", + "zh:4be7289c969218a57b40902e2f359914f8d35a7f97b439140cb711aa21e494bd", + "zh:4cf958e631e99ed6c8b522c9b22e1f1b568c0bdadb01dd002ca7dffb1c927764", + "zh:7a0132c0faca4c4c96aa70808effd6817e28712bf5a39881666ac377b4250acf", + "zh:7d60de08fac427fb045e4590d1b921b6778498eee9eb16f78c64d4c577bde096", + "zh:91003bee5981e99ec3925ce2f452a5f743827f9d0e131a86613549c1464796f0", + "zh:9fe2fe75977c8149e2515fb30c6cc6cfd57b225d4ce592c570d81a3831d7ffa3", + "zh:e210e6be54933ce93e03d0994e520ba289aa01b2c1f70e77afb8f2ee796b0fe3", + "zh:e8793e5f9422f2b31a804e51806595f335b827c9a38db18766960464566f21d5", + ] +} + +provider "registry.opentofu.org/hashicorp/null" { + version = "3.2.4" + hashes = [ + "h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=", + "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", + "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", + "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", + "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", + "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", + "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", + "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", + "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", + "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", + "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", + ] +} + provider "registry.opentofu.org/hashicorp/random" { version = "3.5.1" constraints = "~> 3.5.1" diff --git a/modules/10-services-generic/caddy-proxy/main.tf b/modules/10-services-generic/caddy-proxy/main.tf new file mode 100644 index 0000000..c20d3d5 --- /dev/null +++ b/modules/10-services-generic/caddy-proxy/main.tf @@ -0,0 +1,170 @@ +terraform { + required_providers { + docker = { + source = "kreuzwerker/docker" + } + } +} + +# Generate Caddyfile content +locals { + # Generate the Caddyfile header + caddyfile_header = "# Global options\n{\n admin :2019\n}\n\n" + + # Generate site configurations - using separate HTTP and HTTPS blocks but with different hostnames + site_blocks = flatten([ + for site in var.sites : [ + # HTTPS configuration + "# Site configuration for ${site.domain} (HTTPS)", + "${site.domain}:${var.https_port} {", + " # TLS configuration", + " tls internal", + "", + " # Route configurations", + join("\n\n ", [ + for route in site.routes : <<-ROUTE + # Route: ${route.path} + handle ${route.path} { + reverse_proxy ${route.target_host}:${route.target_port} { + ${route.websocket ? "# WebSocket protocol handling\n header_up Connection \"Upgrade\"\n header_up Upgrade \"websocket\"" : ""} + } + } + ROUTE + ]), + "}", + "", + # HTTP configuration with redirect to HTTPS + "# HTTP redirect for ${site.domain}", + ":${var.http_port} {", + " redir https://${site.domain}:${var.https_port}{uri} permanent", + "}" + ] + ]) + + # Combine everything into the final Caddyfile + caddyfile_content = "${local.caddyfile_header}${join("\n\n", local.site_blocks)}" + + # Define volumes for Caddy + volumes = [ + { + host_path = "${var.volume_path}/Caddyfile" + container_path = "/etc/caddy/Caddyfile" + read_only = false + }, + { + host_path = "${var.volume_path}/data" + container_path = "/data" + read_only = false + }, + { + host_path = "${var.volume_path}/config" + container_path = "/config" + read_only = false + } + ] + + # Create data directories if they don't exist + data_directories = [ + var.volume_path, + "${var.volume_path}/data", + "${var.volume_path}/config" + ] + + # Environment variables - convert to the format docker_container resource expects + env_vars = var.networks != [] ? ["CADDY_INGRESS_NETWORKS=${join(",", var.networks)}"] : [] + + # Health check + healthcheck = { + test = ["CMD", "wget", "--spider", "--quiet", "http://localhost:80/healthz"] + interval = "30s" + timeout = "3s" + retries = 3 + } +} + +# Create data directories +resource "null_resource" "create_directories" { + count = length(local.data_directories) + + provisioner "local-exec" { + command = "mkdir -p ${local.data_directories[count.index]}" + } +} + +# Create Caddyfile +resource "local_file" "caddyfile" { + content = local.caddyfile_content + filename = "${var.volume_path}/Caddyfile" + depends_on = [null_resource.create_directories] +} + +# Pull the image +resource "docker_image" "caddy" { + name = "${var.image}:${var.tag}" +} + +# Create the container +resource "docker_container" "caddy" { + name = var.container_name + image = docker_image.caddy.image_id + + restart = "unless-stopped" + + # Map ports similar to Nginx Proxy Manager + ports { + internal = 80 + external = var.http_port + protocol = "tcp" + } + + ports { + internal = 443 + external = var.https_port + protocol = "tcp" + } + + # Admin interface port + ports { + internal = 2019 # Caddy admin API port + external = var.admin_port + protocol = "tcp" + } + + # Set up volumes + dynamic "volumes" { + for_each = local.volumes + content { + host_path = volumes.value.host_path + container_path = volumes.value.container_path + read_only = volumes.value.read_only + } + } + + # Set environment variables as a list of strings in KEY=VALUE format + env = local.env_vars + + # Set networks + dynamic "networks_advanced" { + for_each = var.networks + content { + name = networks_advanced.value + } + } + + # Set health check + healthcheck { + test = local.healthcheck.test + interval = local.healthcheck.interval + timeout = local.healthcheck.timeout + retries = local.healthcheck.retries + } + + # Add watchtower label if monitoring is enabled + labels { + label = "com.centurylinklabs.watchtower.enable" + value = var.monitoring ? "true" : "false" + } + + # Make sure Caddyfile is created before starting container + depends_on = [local_file.caddyfile] +} diff --git a/modules/10-services-generic/caddy-proxy/outputs.tf b/modules/10-services-generic/caddy-proxy/outputs.tf new file mode 100644 index 0000000..ce893f5 --- /dev/null +++ b/modules/10-services-generic/caddy-proxy/outputs.tf @@ -0,0 +1,19 @@ +output "container_name" { + description = "Name of the Caddy proxy container" + value = docker_container.caddy.name +} + +output "container_ip" { + description = "IP address of the Caddy container" + value = docker_container.caddy.network_data[0].ip_address +} + +output "domains" { + description = "Domains being proxied by Caddy" + value = [for site in var.sites : site.domain] +} + +output "ready" { + description = "Boolean indicating if Caddy is ready" + value = docker_container.caddy.id != "" +} diff --git a/modules/10-services-generic/caddy-proxy/variables.tf b/modules/10-services-generic/caddy-proxy/variables.tf new file mode 100644 index 0000000..0bc1488 --- /dev/null +++ b/modules/10-services-generic/caddy-proxy/variables.tf @@ -0,0 +1,66 @@ +variable "container_name" { + description = "The name of the Caddy container" + type = string + default = "caddy-proxy" +} + +variable "http_port" { + description = "External HTTP port mapping" + type = number + default = 9080 +} + +variable "https_port" { + description = "External HTTPS port mapping" + type = number + default = 9443 +} + +variable "admin_port" { + description = "External admin port mapping" + type = number + default = 9081 +} + +variable "image" { + description = "The image to use for the Caddy container" + type = string + default = "caddy" +} + +variable "tag" { + description = "The tag of the Caddy image" + type = string + default = "latest" +} + +variable "volume_path" { + description = "Base directory for volumes" + type = string + default = "/mnt/appdata/caddy" +} + +variable "monitoring" { + description = "Enable or disable monitoring" + type = bool + default = true +} + +variable "networks" { + description = "List of networks to attach to the Caddy container" + type = list(string) + default = [] +} + +variable "sites" { + description = "List of sites to proxy" + type = list(object({ + domain = string # Domain name (e.g. deploy.yuris.dev) + routes = list(object({ + path = string # Path to match (e.g. "/app/*" or "/" for root) + target_host = string # Target host (e.g. coolify) + target_port = number # Target port (e.g. 8080) + websocket = bool # Whether this route should be treated as websocket + })) + })) +} diff --git a/modules/20-services-apps/coolify/.env.example b/modules/20-services-apps/coolify/.env.example new file mode 100644 index 0000000..101d6b0 --- /dev/null +++ b/modules/20-services-apps/coolify/.env.example @@ -0,0 +1,29 @@ +REGISTRY_URL=ghcr.io + +# App Configuration +APP_ID= +APP_NAME=Coolify +APP_KEY= +ROOT_USERNAME= +ROOT_USER_EMAIL= +ROOT_USER_PASSWORD= +PHP_MEMORY_LIMIT=256M +PHP_FPM_PM_CONTROL=dynamic +PHP_FPM_PM_START_SERVERS=1 +PHP_FPM_PM_MIN_SPARE_SERVERS=1 +PHP_FPM_PM_MAX_SPARE_SERVERS=10 + +# Database Configuration +DB_DATABASE=coolify +DB_USERNAME=coolify +DB_PASSWORD=GENERATE_RANDOM_STRING + +# Redis Configuration +REDIS_PASSWORD=GENERATE_RANDOM_STRING + +# Pusher/Soketi Configuration +APP_NAME=Coolify +SOKETI_DEBUG=false +PUSHER_APP_ID=GENERATE_RANDOM_HEXADECIMAL +PUSHER_APP_KEY=GENERATE_RANDOM_HEXADECIMAL +PUSHER_APP_SECRET=GENERATE_RANDOM_HEXADECIMAL diff --git a/modules/20-services-apps/coolify/caddy.tf b/modules/20-services-apps/coolify/caddy.tf new file mode 100644 index 0000000..3c55820 --- /dev/null +++ b/modules/20-services-apps/coolify/caddy.tf @@ -0,0 +1,37 @@ +module "coolify_caddy" { + source = "../../10-services-generic/caddy-proxy" + container_name = "coolify-caddy" + volume_path = "${var.volume_path}/caddy" + networks = concat([module.coolify_network.name], var.networks) + + # Use custom ports (not exposing 80/443 directly) + http_port = 7080 + https_port = 6443 + admin_port = 7081 + + sites = [ + { + domain = "deploy.yuris.dev" + routes = [ + { + path = "/app/*" # Main WebSocket endpoint for Pusher/Soketi + target_host = local.soketi_container_name + target_port = local.soketi_port + websocket = true + }, + { + path = "/apps/*" # Alternative WebSocket endpoint + target_host = local.soketi_container_name + target_port = local.soketi_port + websocket = true + }, + { + path = "/*" # All other requests go to the main app + target_host = local.app_container_name + target_port = local.app_port + websocket = false + } + ] + } + ] +} diff --git a/modules/20-services-apps/coolify/main.tf b/modules/20-services-apps/coolify/main.tf new file mode 100644 index 0000000..22f422f --- /dev/null +++ b/modules/20-services-apps/coolify/main.tf @@ -0,0 +1,260 @@ +terraform { + required_providers { + dotenv = { + source = "germanbrew/dotenv" + } + } +} + +variable "image_tag" { + description = "The tag for the coolify container image" + type = string + default = "latest" +} + +variable "volume_path" { + description = "Base directory for volumes" + type = string +} + +variable "networks" { + description = "List of networks to which the coolify container should be attached" + type = list(string) + default = [] +} + +locals { + env_file = "${path.module}/.env" + + # Container names + app_container_name = "coolify" + db_container_name = "coolify-db" + redis_container_name = "coolify-redis" + soketi_container_name = "coolify-realtime" + + # Images and tags + app_image = "ghcr.io/coollabsio/coolify" + db_image = "postgres" + redis_image = "redis" + soketi_image = "ghcr.io/coollabsio/coolify-realtime" + + app_tag = var.image_tag != "" ? var.image_tag : "latest" + db_tag = "15-alpine" + redis_tag = "7-alpine" + soketi_tag = "1.0.8" + + monitoring = true + app_port = 8080 + soketi_port = 6001 + + # Volume mappings + app_volumes = [ + { + host_path = "${var.volume_path}/source/.env" + container_path = "/var/www/html/.env" + read_only = true + }, + { + host_path = "${var.volume_path}/ssh" + container_path = "/var/www/html/storage/app/ssh" + read_only = false + }, + { + host_path = "${var.volume_path}/applications" + container_path = "/var/www/html/storage/app/applications" + read_only = false + }, + { + host_path = "${var.volume_path}/databases" + container_path = "/var/www/html/storage/app/databases" + read_only = false + }, + { + host_path = "${var.volume_path}/services" + container_path = "/var/www/html/storage/app/services" + read_only = false + }, + { + host_path = "${var.volume_path}/backups" + container_path = "/var/www/html/storage/app/backups" + read_only = false + }, + { + host_path = "${var.volume_path}/webhooks-during-maintenance" + container_path = "/var/www/html/storage/app/webhooks-during-maintenance" + read_only = false + } + ] + + db_volumes = [ + { + host_path = "${var.volume_path}/db_data" + container_path = "/var/lib/postgresql/data" + read_only = false + } + ] + + redis_volumes = [ + { + host_path = "${var.volume_path}/redis_data" + container_path = "/data" + read_only = false + } + ] + + soketi_volumes = [ + { + host_path = "${var.volume_path}/ssh" + container_path = "/var/www/html/storage/app/ssh" + read_only = false + } + ] + + app_env_vars = { + APP_ENV = "production" + APP_ID = provider::dotenv::get_by_key("APP_ID", local.env_file) + APP_NAME = provider::dotenv::get_by_key("APP_NAME", local.env_file) + APP_KEY = provider::dotenv::get_by_key("APP_KEY", local.env_file) + ROOT_USERNAME = provider::dotenv::get_by_key("ROOT_USERNAME", local.env_file) + ROOT_USER_EMAIL = provider::dotenv::get_by_key("ROOT_USER_EMAIL", local.env_file) + ROOT_USER_PASSWORD = provider::dotenv::get_by_key("ROOT_USER_PASSWORD", local.env_file) + PHP_MEMORY_LIMIT = provider::dotenv::get_by_key("PHP_MEMORY_LIMIT", local.env_file) + PHP_FPM_PM_CONTROL = provider::dotenv::get_by_key("PHP_FPM_PM_CONTROL", local.env_file) + PHP_FPM_PM_START_SERVERS = provider::dotenv::get_by_key("PHP_FPM_PM_START_SERVERS", local.env_file) + PHP_FPM_PM_MIN_SPARE_SERVERS = provider::dotenv::get_by_key("PHP_FPM_PM_MIN_SPARE_SERVERS", local.env_file) + PHP_FPM_PM_MAX_SPARE_SERVERS = provider::dotenv::get_by_key("PHP_FPM_PM_MAX_SPARE_SERVERS", local.env_file) + DB_DATABASE = provider::dotenv::get_by_key("DB_DATABASE", local.env_file) + DB_USERNAME = provider::dotenv::get_by_key("DB_USERNAME", local.env_file) + DB_PASSWORD = provider::dotenv::get_by_key("DB_PASSWORD", local.env_file) + REDIS_PASSWORD = provider::dotenv::get_by_key("REDIS_PASSWORD", local.env_file) + } + + db_env_vars = { + POSTGRES_USER = provider::dotenv::get_by_key("DB_USERNAME", local.env_file) + POSTGRES_PASSWORD = provider::dotenv::get_by_key("DB_PASSWORD", local.env_file) + POSTGRES_DB = provider::dotenv::get_by_key("DB_DATABASE", local.env_file) + } + + redis_env_vars = { + REDIS_PASSWORD = provider::dotenv::get_by_key("REDIS_PASSWORD", local.env_file) + } + + soketi_env_vars = { + APP_NAME = provider::dotenv::get_by_key("APP_NAME", local.env_file) + SOKETI_DEBUG = provider::dotenv::get_by_key("SOKETI_DEBUG", local.env_file) + SOKETI_DEFAULT_APP_ID = provider::dotenv::get_by_key("PUSHER_APP_ID", local.env_file) + SOKETI_DEFAULT_APP_KEY = provider::dotenv::get_by_key("PUSHER_APP_KEY", local.env_file) + SOKETI_DEFAULT_APP_SECRET = provider::dotenv::get_by_key("PUSHER_APP_SECRET", local.env_file) + } + + healthchecks = { + db = { + test = ["CMD-SHELL", "pg_isready -U coolify -d coolify"] + interval = "5s" + timeout = "2s" + retries = 10 + } + + redis = { + test = ["CMD", "redis-cli", "ping"] + interval = "5s" + timeout = "2s" + retries = 10 + } + + soketi = { + test = ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + interval = "5s" + timeout = "2s" + retries = 10 + } + + app = { + test = ["CMD-SHELL", "curl --fail http://127.0.0.1:8080/api/health || exit 1"] + interval = "5s" + timeout = "2s" + retries = 10 + } + } +} + +module "coolify_network" { + source = "../../01-networking/docker-network" + name = "coolify" + driver = "bridge" +} + +module "postgres" { + source = "../../10-services-generic/docker-service" + container_name = local.db_container_name + image = local.db_image + tag = local.db_tag + volumes = local.db_volumes + puid = 9999 + pgid = 9999 + env_vars = local.db_env_vars + networks = [module.coolify_network.name] + monitoring = local.monitoring + healthcheck = local.healthchecks.db +} + +module "redis" { + source = "../../10-services-generic/docker-service" + container_name = local.redis_container_name + image = local.redis_image + tag = local.redis_tag + volumes = local.redis_volumes + puid = 9999 + pgid = 9999 + env_vars = local.redis_env_vars + networks = [module.coolify_network.name] + monitoring = local.monitoring + command = ["redis-server", "--save", "20", "1", "--loglevel", "warning", "--requirepass", provider::dotenv::get_by_key("REDIS_PASSWORD", local.env_file)] + healthcheck = local.healthchecks.redis +} + +module "soketi" { + source = "../../10-services-generic/docker-service" + container_name = local.soketi_container_name + image = local.soketi_image + tag = local.soketi_tag + volumes = local.soketi_volumes + puid = 9999 + pgid = 9999 + env_vars = local.soketi_env_vars + networks = [module.coolify_network.name] + monitoring = local.monitoring + healthcheck = local.healthchecks.soketi +} + +module "coolify" { + source = "../../10-services-generic/docker-service" + container_name = local.app_container_name + image = local.app_image + tag = local.app_tag + volumes = local.app_volumes + puid = 9999 + pgid = 9999 + env_vars = local.app_env_vars + networks = concat([module.coolify_network.name], var.networks) + monitoring = local.monitoring + healthcheck = local.healthchecks.app + host_mappings = [ + { + host = "host.docker.internal" + ip = "host-gateway" + } + ] + + depends_on = [module.postgres, module.redis, module.soketi] +} + +output "service_definition" { + description = "Service definition with ingress configuration" + value = { + name = local.app_container_name + primary_port = local.app_port + endpoint = "http://${local.app_container_name}:${local.app_port}" + subdomains = ["deploy"] + } +} diff --git a/services/main.tf b/services/main.tf index d8cea4d..77210a8 100644 --- a/services/main.tf +++ b/services/main.tf @@ -24,6 +24,12 @@ module "actualbudget" { networks = [module.homelab_docker_network.name] } +module "coolify" { + source = "${local.module_dir}/20-services-apps/coolify" + volume_path = "${local.volume_host}/coolify" + networks = [module.homelab_docker_network.name] +} + module "emulatorjs" { source = "${local.module_dir}/20-services-apps/emulatorjs" volume_path = "${local.volume_host}/emulatorjs"