From 618ca06093f4bf541218a2b103e9b9027f3bbe9b Mon Sep 17 00:00:00 2001
From: Matthew Baggett <matthew@baggett.me>
Date: Tue, 26 Nov 2024 10:50:53 +0100
Subject: [PATCH] Treafik woz 'ere

---
 docker/network/inputs.tf                      |  12 ++
 docker/network/locals.tf                      |   7 +-
 docker/network/network.tf                     |  15 ++-
 docker/network/outputs.tf                     |   6 +
 docker/network/subnet.tf                      |  14 ++
 docker/network/terraform.tf                   |   4 +
 docker/service/{image.tf => image_mirror.tf}  |   8 +-
 docker/service/inputs.tf                      |  49 ++++++-
 docker/service/labels.tf                      |  27 ++++
 docker/service/locals.tf                      |  13 +-
 docker/service/networks.tf                    |   4 +
 docker/service/service.tf                     |  40 ++++--
 docker/socket-proxy/inputs.tf                 |   5 +-
 docker/socket-proxy/output.tf                 |   6 +
 docker/socket-proxy/socket-proxy.tf           |  33 ++---
 docker/volume/output.tf                       |   3 +
 .../forgejo/actions-runner/forgejo-runner.tf  |   4 +-
 products/frigate/frigate.tf                   |  55 ++++++++
 products/frigate/inputs.tf                    |  82 ++++++++++++
 products/frigate/labels.tf                    |  24 ++++
 products/frigate/output.tf                    |   3 +
 products/frigate/terraform.tf                 |  11 ++
 products/gitea/actions-runner/gitea-runner.tf |   4 +-
 products/github/actions-runner/gha-runner.tf  |   4 +-
 products/homeassistant/homeassistant.tf       |  13 ++
 products/homeassistant/inputs.tf              |  35 +++++
 products/homeassistant/outputs.tf             |   3 +
 products/homeassistant/terraform.tf           |  15 +++
 products/minio/inputs.tf                      |  24 ++++
 products/minio/minio.tf                       |  64 ++++++++++
 products/minio/outputs.tf                     |   3 +
 products/minio/terraform.tf                   |  15 +++
 products/mitmproxy/inputs.tf                  |  25 ++++
 products/mitmproxy/mitmproxy.tf               |  30 +++++
 products/mitmproxy/outputs.tf                 |   3 +
 products/mitmproxy/terraform.tf               |  11 ++
 products/pgbackweb/inputs.tf                  |   4 +
 products/pgbackweb/pgbackweb.tf               |   1 +
 products/portainer/ui/inputs.tf               |  41 ++++--
 products/portainer/ui/output.tf               |   2 +-
 products/portainer/ui/ui.tf                   | 120 ++++--------------
 products/smokeping/inputs.tf                  |  18 +++
 products/smokeping/outputs.tf                 |   3 +
 products/smokeping/smokeping.tf               |  15 +++
 products/smokeping/terraform.tf               |  16 +++
 products/statping/inputs.tf                   |   8 ++
 products/statping/nginx.tf                    |  24 ----
 products/statping/outputs.tf                  |   2 +-
 products/statping/statping.tf                 |   3 +-
 products/traefik/docker-socket-proxy.tf       |   5 +
 products/traefik/hello.tf                     |  14 ++
 products/traefik/inputs.tf                    |  43 +++++++
 products/traefik/network.tf                   |   6 +
 products/traefik/outputs.tf                   |   6 +
 products/traefik/terraform.tf                 |  13 ++
 products/traefik/traefik.tf                   |  69 ++++++++++
 products/watchtower/outputs.tf                |   3 +
 57 files changed, 891 insertions(+), 199 deletions(-)
 create mode 100644 docker/network/subnet.tf
 rename docker/service/{image.tf => image_mirror.tf} (76%)
 create mode 100644 docker/service/labels.tf
 create mode 100644 docker/service/networks.tf
 create mode 100644 docker/socket-proxy/output.tf
 create mode 100644 products/frigate/frigate.tf
 create mode 100644 products/frigate/inputs.tf
 create mode 100644 products/frigate/labels.tf
 create mode 100644 products/frigate/output.tf
 create mode 100644 products/frigate/terraform.tf
 create mode 100644 products/homeassistant/homeassistant.tf
 create mode 100644 products/homeassistant/inputs.tf
 create mode 100644 products/homeassistant/outputs.tf
 create mode 100644 products/homeassistant/terraform.tf
 create mode 100644 products/minio/inputs.tf
 create mode 100644 products/minio/minio.tf
 create mode 100644 products/minio/outputs.tf
 create mode 100644 products/minio/terraform.tf
 create mode 100644 products/mitmproxy/inputs.tf
 create mode 100644 products/mitmproxy/mitmproxy.tf
 create mode 100644 products/mitmproxy/outputs.tf
 create mode 100644 products/mitmproxy/terraform.tf
 create mode 100644 products/smokeping/inputs.tf
 create mode 100644 products/smokeping/outputs.tf
 create mode 100644 products/smokeping/smokeping.tf
 create mode 100644 products/smokeping/terraform.tf
 delete mode 100644 products/statping/nginx.tf
 create mode 100644 products/traefik/docker-socket-proxy.tf
 create mode 100644 products/traefik/hello.tf
 create mode 100644 products/traefik/inputs.tf
 create mode 100644 products/traefik/network.tf
 create mode 100644 products/traefik/outputs.tf
 create mode 100644 products/traefik/terraform.tf
 create mode 100644 products/traefik/traefik.tf
 create mode 100644 products/watchtower/outputs.tf

diff --git a/docker/network/inputs.tf b/docker/network/inputs.tf
index ab10d20..793178d 100644
--- a/docker/network/inputs.tf
+++ b/docker/network/inputs.tf
@@ -7,3 +7,15 @@ variable "labels" {
   default     = {}
   description = "A map of labels to apply to the service"
 }
+
+variable "network_name" {
+  type        = string
+  description = "Override the automatically selected name of the network"
+  default     = null
+}
+
+variable "subnet" {
+  type        = string
+  description = "The subnet to use for the network."
+  default     = null //"172.16.0.0/16"
+}
diff --git a/docker/network/locals.tf b/docker/network/locals.tf
index 6f5cd5f..b01e7fb 100644
--- a/docker/network/locals.tf
+++ b/docker/network/locals.tf
@@ -1,9 +1,12 @@
 locals {
-  network_name = var.stack_name
+  // Concat up the network name
+  network_name = var.network_name != null ? "${var.stack_name}-${var.network_name}" : var.stack_name
+
+  // Attach labels
   labels = merge(var.labels, {
     "com.docker.stack.namespace" = var.stack_name
     "ooo.grey.network.stack"     = var.stack_name
     "ooo.grey.network.name"      = local.network_name
-    #"ooo.grey.network.created"    = timestamp()
+    "ooo.grey.network.subnet"    = local.subnet
   })
 }
\ No newline at end of file
diff --git a/docker/network/network.tf b/docker/network/network.tf
index 7ef7ab9..f666b67 100644
--- a/docker/network/network.tf
+++ b/docker/network/network.tf
@@ -1,6 +1,13 @@
 resource "docker_network" "instance" {
-  name   = local.network_name
-  driver = "overlay"
+  name        = local.network_name
+  driver      = "overlay"
+  attachable  = true
+  ipam_driver = "default"
+  ipam_config {
+    aux_address = {}
+    subnet      = local.subnet
+    gateway     = local.gateway
+  }
 
   # Attach labels
   dynamic "labels" {
@@ -10,4 +17,8 @@ resource "docker_network" "instance" {
       value = labels.value
     }
   }
+
+  lifecycle {
+    create_before_destroy = false
+  }
 }
\ No newline at end of file
diff --git a/docker/network/outputs.tf b/docker/network/outputs.tf
index 70f01ea..23cb35c 100644
--- a/docker/network/outputs.tf
+++ b/docker/network/outputs.tf
@@ -1,3 +1,9 @@
 output "network" {
   value = docker_network.instance.id
+}
+output "name" {
+  value = docker_network.instance.name
+}
+output "id" {
+  value = docker_network.instance.id
 }
\ No newline at end of file
diff --git a/docker/network/subnet.tf b/docker/network/subnet.tf
new file mode 100644
index 0000000..531515b
--- /dev/null
+++ b/docker/network/subnet.tf
@@ -0,0 +1,14 @@
+resource "random_integer" "upper_mid_byte" {
+  min = 18
+  max = 31
+}
+resource "random_integer" "lower_mid_byte" {
+  min = 10
+  max = 254
+}
+locals {
+  // Generate a subnet
+  subnet = var.subnet != null ? var.subnet : "172.${random_integer.upper_mid_byte.result}.${random_integer.lower_mid_byte.result}.0/24"
+  // Calculate the gateway from the subnet
+  gateway = cidrhost(local.subnet, 1)
+}
\ No newline at end of file
diff --git a/docker/network/terraform.tf b/docker/network/terraform.tf
index 0814b57..e7ca61a 100644
--- a/docker/network/terraform.tf
+++ b/docker/network/terraform.tf
@@ -5,6 +5,10 @@ terraform {
       source  = "kreuzwerker/docker"
       version = "~>3.0"
     }
+    random = {
+      source  = "hashicorp/random"
+      version = "~>3.3"
+    }
   }
 }
 
diff --git a/docker/service/image.tf b/docker/service/image_mirror.tf
similarity index 76%
rename from docker/service/image.tf
rename to docker/service/image_mirror.tf
index 318a6dd..c61675b 100644
--- a/docker/service/image.tf
+++ b/docker/service/image_mirror.tf
@@ -1,17 +1,19 @@
+/*
 resource "docker_image" "mirror" {
-  count         = var.mirror != null ? 1 : 0
+  count         = local.enable_mirror ? 1 : 0
   name          = data.docker_registry_image.image.name
   pull_triggers = [data.docker_registry_image.image.sha256_digest]
   force_remove  = false
 }
 resource "docker_tag" "mirror" {
-  count        = var.mirror != null ? 1 : 0
+  count        = local.enable_mirror ? 1 : 0
   source_image = docker_image.mirror[0].name
   target_image = var.mirror
 }
 resource "docker_registry_image" "mirror" {
-  count         = var.mirror != null ? 1 : 0
+  count         = local.enable_mirror ? 1 : 0
   depends_on    = [docker_tag.mirror[0]]
   name          = docker_tag.mirror[0].target_image
   keep_remotely = true
 }
+*/
\ No newline at end of file
diff --git a/docker/service/inputs.tf b/docker/service/inputs.tf
index 7ff3195..9e0b691 100644
--- a/docker/service/inputs.tf
+++ b/docker/service/inputs.tf
@@ -19,6 +19,11 @@ variable "restart_policy" {
     condition     = var.restart_policy == "any" || var.restart_policy == "on-failure" || var.restart_policy == "none"
   }
 }
+variable "restart_delay" {
+  type        = string
+  default     = "0s"
+  description = "The delay before restarting the service."
+}
 variable "one_shot" {
   type        = bool
   default     = false
@@ -53,9 +58,12 @@ variable "volumes" {
   description = "A map of volume names to create and mount. The key is the volume name, and the value is the mount point."
 }
 variable "remote_volumes" {
-  type        = map(string)
+  type = map(object({
+    id     = string
+    driver = string
+  }))
   default     = {}
-  description = "A map of remote volumes to mount into the container."
+  description = "A map of remote volumes to mount into the container. The key is the source, and the value is the target."
 }
 variable "mounts" {
   type        = map(string)
@@ -76,8 +84,8 @@ variable "ports" {
   default     = []
   description = "A map of port mappings to expose on the host. The key is the host port, and the value is the container port."
   validation {
-    error_message = "Host Ports must be between 1024 and 65535."
-    condition     = alltrue([for port in var.ports : port.host >= 1024 && port.host <= 65535])
+    error_message = "Host Ports must be between 1 and 65535."
+    condition     = alltrue([for port in var.ports : port.host >= 1 && port.host <= 65535])
   }
   validation {
     error_message = "Container Ports must be between 1 and 65535."
@@ -105,6 +113,11 @@ variable "parallelism" {
   type        = number
   description = "The number of instances to run."
 }
+variable "parallelism_per_node" {
+  default     = 0
+  type        = number
+  description = "The maximum number of instances to run per node. 0 means no limit."
+}
 variable "update_waves" {
   default     = 3
   type        = number
@@ -144,4 +157,32 @@ variable "converge_timeout" {
   default     = "2m"
   type        = string
   description = "The timeout for the service to converge."
+}
+variable "traefik" {
+  default = null
+  type = object({
+    domain = string
+    port   = optional(number)
+  })
+  description = "Whether to enable traefik for the service."
+}
+variable "limit_cpu" {
+  default     = null
+  type        = number
+  description = "The CPU limit for the service."
+}
+variable "limit_ram_mb" {
+  default     = null
+  type        = number
+  description = "The RAM limit for the service, measured in megabytes."
+}
+variable "reserved_cpu" {
+  default     = null
+  type        = number
+  description = "The CPU reservation for the service."
+}
+variable "reserved_ram_mb" {
+  default     = null
+  type        = number
+  description = "The RAM reservation for the service, measured in megabytes."
 }
\ No newline at end of file
diff --git a/docker/service/labels.tf b/docker/service/labels.tf
new file mode 100644
index 0000000..688051c
--- /dev/null
+++ b/docker/service/labels.tf
@@ -0,0 +1,27 @@
+locals {
+  # Define service labels en-masse
+  labels = merge(var.labels, {
+    "com.docker.stack.namespace"    = var.stack_name
+    "com.docker.stack.image"        = data.docker_registry_image.image.name
+    "ooo.grey.service.stack"        = var.stack_name
+    "ooo.grey.service.name"         = var.service_name
+    "ooo.grey.service.image"        = data.docker_registry_image.image.name
+    "ooo.grey.service.image.digest" = data.docker_registry_image.image.sha256_digest
+  }, local.traefik_labels)
+
+  # Calculate the traefik labels to use if enabled
+  traefik_labels = merge(
+    (var.traefik == null ? {
+      "traefik.enable" = "false"
+      } : {
+      "traefik.enable"                                              = "true"
+      "traefik.http.routers.${local.service_name}.rule"             = "Host(`${var.traefik.domain}`)"
+      "traefik.http.routers.${local.service_name}.entrypoints"      = "websecure"
+      "traefik.http.routers.${local.service_name}.tls.certresolver" = "default"
+    }),
+    (try(var.traefik.port, null) == null ? {} : {
+      "traefik.http.services.${local.service_name}.loadbalancer.server.port" = var.traefik.port
+    })
+  )
+
+}
\ No newline at end of file
diff --git a/docker/service/locals.tf b/docker/service/locals.tf
index f0bfe6e..c826ec9 100644
--- a/docker/service/locals.tf
+++ b/docker/service/locals.tf
@@ -5,16 +5,9 @@ locals {
     substr(var.service_name, 0, 63 - 1 - 20),
   ])
 
-  # Define service labels en-masse
-  labels = merge(var.labels, {
-    "com.docker.stack.namespace"    = var.stack_name
-    "com.docker.stack.image"        = data.docker_registry_image.image.name
-    "ooo.grey.service.stack"        = var.stack_name
-    "ooo.grey.service.name"         = var.service_name
-    "ooo.grey.service.image"        = data.docker_registry_image.image.name
-    "ooo.grey.service.image.digest" = data.docker_registry_image.image.sha256_digest
-  })
+  enable_mirror = false // var.mirror != null
 
   # Calculate the docker image to use
-  image = var.mirror != null ? "${docker_registry_image.mirror[0].name}@${docker_registry_image.mirror[0].sha256_digest}" : "${data.docker_registry_image.image.name}@${data.docker_registry_image.image.sha256_digest}"
+  #image = local.enable_mirror ? "${docker_registry_image.mirror[0].name}@${docker_registry_image.mirror[0].sha256_digest}" : "${data.docker_registry_image.image.name}@${data.docker_registry_image.image.sha256_digest}"
+  image = "${data.docker_registry_image.image.name}@${data.docker_registry_image.image.sha256_digest}"
 }
\ No newline at end of file
diff --git a/docker/service/networks.tf b/docker/service/networks.tf
new file mode 100644
index 0000000..1e1133d
--- /dev/null
+++ b/docker/service/networks.tf
@@ -0,0 +1,4 @@
+data "docker_network" "networks" {
+  count = var.networks != null ? length(var.networks) : 0
+  name  = var.networks[count.index]
+}
\ No newline at end of file
diff --git a/docker/service/service.tf b/docker/service/service.tf
index 3bf63d1..6f64dc1 100644
--- a/docker/service/service.tf
+++ b/docker/service/service.tf
@@ -17,9 +17,10 @@ resource "docker_service" "instance" {
       dynamic "mounts" {
         for_each = var.volumes
         content {
-          target = mounts.value
-          source = docker_volume.volume[mounts.key].id
-          type   = "volume"
+          source    = docker_volume.volume[mounts.key].id
+          target    = mounts.value
+          type      = "volume"
+          read_only = false # Nice assumption bro.
         }
       }
 
@@ -27,9 +28,10 @@ resource "docker_service" "instance" {
       dynamic "mounts" {
         for_each = var.remote_volumes
         content {
-          target = mounts.value
-          source = mounts.key
-          type   = "volume"
+          source    = mounts.value.id
+          target    = mounts.key
+          type      = "volume"
+          read_only = false # Nice assumption bro.
         }
       }
 
@@ -37,8 +39,8 @@ resource "docker_service" "instance" {
       dynamic "mounts" {
         for_each = var.mounts
         content {
-          target    = mounts.value
           source    = mounts.key
+          target    = mounts.value
           type      = "bind"
           read_only = false # Nice assumption bro.
         }
@@ -50,7 +52,7 @@ resource "docker_service" "instance" {
         content {
           config_id   = module.config[configs.key].id
           config_name = module.config[configs.key].name
-          file_name   = configs.value
+          file_name   = configs.key
         }
       }
 
@@ -83,27 +85,41 @@ resource "docker_service" "instance" {
 
     # Apply the networks
     dynamic "networks_advanced" {
-      for_each = var.networks
+      for_each = data.docker_network.networks
       content {
-        name = networks_advanced.value
+        name = networks_advanced.value.id
       }
     }
 
     # Apply restart policy
     restart_policy {
       condition    = var.one_shot ? "none" : var.restart_policy
-      delay        = "0s"
+      delay        = var.restart_delay
       window       = "0s"
       max_attempts = 0
     }
 
+    # Apply the placement constraints
     placement {
-      constraints = var.placement_constraints
+      max_replicas = var.parallelism_per_node
+      constraints  = var.placement_constraints
       platforms {
         architecture = var.processor_architecture
         os           = var.operating_system
       }
     }
+
+    # Apply the resource limits and reservations
+    resources {
+      limits {
+        memory_bytes = var.limit_ram_mb != null ? 1024 * 1024 * var.limit_ram_mb : 0
+        nano_cpus    = var.limit_cpu != null ? (1000000000 / 100) * var.limit_cpu : 0
+      }
+      reservation {
+        memory_bytes = var.reserved_ram_mb != null ? 1024 * 1024 * var.reserved_ram_mb : 0
+        nano_cpus    = var.reserved_cpu != null ? (1000000000 / 100) * var.reserved_cpu : 0
+      }
+    }
   }
 
   # Global deploy
diff --git a/docker/socket-proxy/inputs.tf b/docker/socket-proxy/inputs.tf
index 3feea56..9cb6f79 100644
--- a/docker/socket-proxy/inputs.tf
+++ b/docker/socket-proxy/inputs.tf
@@ -19,7 +19,8 @@ variable "service_name" {
   description = "The name of the service to create."
 }
 variable "placement_constraints" {
-  default     = ["node.role == manager"]
+  default     = []
   type        = list(string)
   description = "Docker Swarm placement constraints"
-}
\ No newline at end of file
+}
+
diff --git a/docker/socket-proxy/output.tf b/docker/socket-proxy/output.tf
new file mode 100644
index 0000000..3e730c4
--- /dev/null
+++ b/docker/socket-proxy/output.tf
@@ -0,0 +1,6 @@
+output "docker_service" {
+  value = module.service.docker_service
+}
+output "network" {
+  value = module.network.network
+}
\ No newline at end of file
diff --git a/docker/socket-proxy/socket-proxy.tf b/docker/socket-proxy/socket-proxy.tf
index 55f8680..e54a187 100644
--- a/docker/socket-proxy/socket-proxy.tf
+++ b/docker/socket-proxy/socket-proxy.tf
@@ -1,14 +1,17 @@
 module "network" {
-  source     = "../network"
-  name       = "docker-socket-proxy"
-  stack_name = var.stack_name
+  source       = "../network"
+  network_name = "docker-socket-proxy"
+  stack_name   = var.stack_name
 }
 module "service" {
-  source       = "../service"
-  image        = "${var.docker_socket_proxy_image}:${var.docker_socket_proxy_version}"
-  command      = ["/docker-entrypoint.sh", "sockd-username"]
-  stack_name   = var.stack_name
-  service_name = var.service_name
+  source                = "../service"
+  image                 = "${var.docker_socket_proxy_image}:${var.docker_socket_proxy_version}"
+  stack_name            = var.stack_name
+  service_name          = var.service_name
+  placement_constraints = concat(["node.role == manager"], var.placement_constraints)
+  global                = true
+  networks              = [module.network.network]
+  mounts                = { "/var/run/docker.sock" = "/var/run/docker.sock" }
   environment_variables = {
     SWARM      = 1
     SERVICES   = 1
@@ -17,18 +20,4 @@ module "service" {
     NODES      = 1
     NETWORKS   = 1
   }
-  placement_constraints = var.placement_constraints
-  global                = true
-  networks              = [module.network.network.id]
-  mounts = [
-    {
-      target    = "/var/run/docker.sock"
-      source    = "/var/run/docker.sock"
-      read_only = false
-      type      = "bind"
-    }
-  ]
 }
-output "network" {
-  value = module.network.network
-}
\ No newline at end of file
diff --git a/docker/volume/output.tf b/docker/volume/output.tf
index 03d5415..91fe32f 100644
--- a/docker/volume/output.tf
+++ b/docker/volume/output.tf
@@ -1,3 +1,6 @@
 output "source" {
   value = docker_volume.volume.id
+}
+output "volume" {
+  value = docker_volume.volume
 }
\ No newline at end of file
diff --git a/products/forgejo/actions-runner/forgejo-runner.tf b/products/forgejo/actions-runner/forgejo-runner.tf
index f979346..eaeb8b0 100644
--- a/products/forgejo/actions-runner/forgejo-runner.tf
+++ b/products/forgejo/actions-runner/forgejo-runner.tf
@@ -13,9 +13,7 @@ module "forgejo_actions_runner" {
     forgejo_RUNNER_REGISTRATION_TOKEN = var.forgejo_token
     CONFIG_FILE                       = "/config.yaml"
   }
-  mounts = {
-    "/var/run/docker.sock" = "/var/run/docker.sock"
-  }
+  mounts = { "/var/run/docker.sock" = "/var/run/docker.sock" }
   configs = {
     forgejo-config = yamlencode({
       name_prefix = ["forgejo-config", var.stack_name, var.service_name]
diff --git a/products/frigate/frigate.tf b/products/frigate/frigate.tf
new file mode 100644
index 0000000..0e3d811
--- /dev/null
+++ b/products/frigate/frigate.tf
@@ -0,0 +1,55 @@
+data "docker_registry_image" "frigate" {
+  name = "ghcr.io/blakeblackshear/frigate:stable"
+}
+
+resource "docker_container" "frigate" {
+  image        = "${data.docker_registry_image.frigate.name}@${data.docker_registry_image.frigate.sha256_digest}"
+  name         = local.container_name
+  restart      = "unless-stopped"
+  privileged   = "true"
+  shm_size     = var.shm_size_mb
+  network_mode = "bridge"
+  env = [
+    "FRIGATE_RTSP_PASSWORD=${var.frigate_rtsp_password}"
+  ]
+  dynamic "devices" {
+    for_each = var.devices
+    content {
+      host_path      = devices.value.host_path
+      container_path = devices.value.container_path
+      permissions    = devices.value.permissions
+    }
+  }
+  dynamic "volumes" {
+    for_each = var.volumes
+    content {
+      container_path = volumes.value
+      host_path      = volumes.key
+      read_only      = false
+    }
+  }
+  dynamic "ports" {
+    for_each = var.ports
+    content {
+      internal = ports.value.container
+      external = ports.value.host
+      protocol = ports.value.protocol
+    }
+  }
+  dynamic "networks_advanced" {
+    for_each = var.networks
+    content {
+      name = networks_advanced.value
+    }
+  }
+  dynamic "labels" {
+    for_each = local.labels
+    content {
+      label = labels.key
+      value = labels.value
+    }
+  }
+  lifecycle {
+    create_before_destroy = false
+  }
+}
\ No newline at end of file
diff --git a/products/frigate/inputs.tf b/products/frigate/inputs.tf
new file mode 100644
index 0000000..e1cf1ce
--- /dev/null
+++ b/products/frigate/inputs.tf
@@ -0,0 +1,82 @@
+variable "stack_name" {
+  type        = string
+  description = "The name of the stack to deploy the service to."
+}
+variable "shm_size_mb" {
+  default     = 256
+  type        = number
+  description = "The size of the shared memory segment in MB"
+}
+variable "networks" {
+  type        = list(string)
+  default     = []
+  description = "A list of network names to attach the service to."
+}
+variable "frigate_rtsp_password" {
+  type        = string
+  description = "The password to use for the RTSP streams"
+  default     = ""
+}
+variable "devices" {
+  type = list(object({
+    host_path      = string
+    container_path = string
+    permissions    = optional(string, "rwm")
+  }))
+  description = "The devices to mount into the container"
+}
+variable "volumes" {
+  type        = map(string)
+  default     = {}
+  description = "A map of volume names to create and mount. The key is the volume name, and the value is the mount point."
+}
+
+variable "ports" {
+  type = list(object({
+    host      = number
+    container = number
+    protocol  = optional(string, "tcp")
+  }))
+  default = [
+    {
+      container = 5000
+      host      = 5000
+      protocol  = "tcp"
+    },
+    {
+      container = 1935
+      host      = 1935
+      protocol  = "tcp"
+    },
+    {
+      container = 8554
+      host      = 8554
+      protocol  = "tcp"
+    },
+    {
+      container = 8555
+      host      = 8555
+      protocol  = "tcp"
+    },
+    {
+      container = 8555
+      host      = 8555
+      protocol  = "udp"
+    }
+  ]
+}
+
+variable "traefik" {
+  default = null
+  type = object({
+    domain = string
+    port   = optional(number, 5000)
+  })
+  description = "Whether to enable traefik for the service."
+}
+
+variable "labels" {
+  type        = map(string)
+  default     = {}
+  description = "A map of labels to apply to the service"
+}
diff --git a/products/frigate/labels.tf b/products/frigate/labels.tf
new file mode 100644
index 0000000..2cf9289
--- /dev/null
+++ b/products/frigate/labels.tf
@@ -0,0 +1,24 @@
+
+locals {
+  container_name = "frigate"
+  # Define service labels en-masse
+  labels = merge({
+    "com.docker.stack.namespace"    = var.stack_name
+    "com.docker.stack.image"        = data.docker_registry_image.frigate.name
+    "ooo.grey.service.stack"        = var.stack_name
+    "ooo.grey.service.name"         = local.container_name
+    "ooo.grey.service.image"        = data.docker_registry_image.frigate.name
+    "ooo.grey.service.image.digest" = data.docker_registry_image.frigate.sha256_digest
+  }, local.traefik_labels, var.labels)
+
+  # Calculate the traefik labels to use if enabled
+  traefik_labels = var.traefik != null ? {
+    "traefik.enable"                                                         = "true"
+    "traefik.http.routers.${local.container_name}.rule"                      = "Host(`${var.traefik.domain}`)"
+    "traefik.http.routers.${local.container_name}.entrypoints"               = "websecure"
+    "traefik.http.routers.${local.container_name}.tls.certresolver"          = "default"
+    "traefik.http.services.${local.container_name}.loadbalancer.server.port" = 5000
+    } : {
+    "traefik.enable" = "false"
+  }
+}
\ No newline at end of file
diff --git a/products/frigate/output.tf b/products/frigate/output.tf
new file mode 100644
index 0000000..3b5a914
--- /dev/null
+++ b/products/frigate/output.tf
@@ -0,0 +1,3 @@
+output "endpoint" {
+  value = try("https://${var.traefik.domain}", "unknown")
+}
\ No newline at end of file
diff --git a/products/frigate/terraform.tf b/products/frigate/terraform.tf
new file mode 100644
index 0000000..0814b57
--- /dev/null
+++ b/products/frigate/terraform.tf
@@ -0,0 +1,11 @@
+terraform {
+  required_version = "~> 1.6"
+  required_providers {
+    docker = {
+      source  = "kreuzwerker/docker"
+      version = "~>3.0"
+    }
+  }
+}
+
+
diff --git a/products/gitea/actions-runner/gitea-runner.tf b/products/gitea/actions-runner/gitea-runner.tf
index f472446..2a3004a 100644
--- a/products/gitea/actions-runner/gitea-runner.tf
+++ b/products/gitea/actions-runner/gitea-runner.tf
@@ -12,9 +12,7 @@ module "gitea_actions_runner" {
     GITEA_RUNNER_REGISTRATION_TOKEN = var.gitea_token
     CONFIG_FILE                     = "/config.yaml"
   }
-  mounts = {
-    "/var/run/docker.sock" = "/var/run/docker.sock"
-  }
+  mounts = { "/var/run/docker.sock" = "/var/run/docker.sock" }
   configs = {
     gitea-config = {
       name_prefix = ["gitea-config", var.stack_name, var.service_name]
diff --git a/products/github/actions-runner/gha-runner.tf b/products/github/actions-runner/gha-runner.tf
index 9ef6057..cec7c9f 100644
--- a/products/github/actions-runner/gha-runner.tf
+++ b/products/github/actions-runner/gha-runner.tf
@@ -15,7 +15,5 @@ module "github_actions_runner" {
     EPHEMERAL           = true
     DISABLE_AUTO_UPDATE = "disable_updates"
   }
-  mounts = {
-    "/var/run/docker.sock" = "/var/run/docker.sock"
-  }
+  mounts = { "/var/run/docker.sock" = "/var/run/docker.sock" }
 }
\ No newline at end of file
diff --git a/products/homeassistant/homeassistant.tf b/products/homeassistant/homeassistant.tf
new file mode 100644
index 0000000..0160e34
--- /dev/null
+++ b/products/homeassistant/homeassistant.tf
@@ -0,0 +1,13 @@
+module "homeassistant" {
+  source                = "../../docker/service"
+  stack_name            = var.stack_name
+  service_name          = "homeassistant"
+  image                 = var.default_image
+  environment_variables = merge({ TZ = "Europe/London" }, var.environment_variables)
+  mounts                = var.mounts
+  networks              = var.networks
+  placement_constraints = var.placement_constraints
+  ports                 = [{ host = 8123, container = 8123 }]
+  traefik               = var.traefik
+  converge_timeout      = "5m"
+}
\ No newline at end of file
diff --git a/products/homeassistant/inputs.tf b/products/homeassistant/inputs.tf
new file mode 100644
index 0000000..e6df592
--- /dev/null
+++ b/products/homeassistant/inputs.tf
@@ -0,0 +1,35 @@
+variable "stack_name" {
+  default     = "homeassistant"
+  type        = string
+  description = "The name of the stack to create."
+}
+
+variable "default_image" {
+  default     = "ghcr.io/home-assistant/home-assistant:stable"
+  type        = string
+  description = "The image to use for the homeassistant service"
+}
+
+variable "environment_variables" {
+  type        = map(string)
+  default     = {}
+  description = "A map of environment variables to set in the container."
+}
+variable "mounts" {
+  type        = map(string)
+  default     = {}
+  description = "A map of host paths to container paths to mount. The key is the host path, and the value is the container path."
+}
+variable "networks" {
+  type        = list(string)
+  default     = []
+  description = "A list of network names to attach the service to."
+}
+variable "placement_constraints" {
+  default     = []
+  type        = list(string)
+  description = "Docker Swarm placement constraints"
+}
+variable "traefik" {
+
+}
\ No newline at end of file
diff --git a/products/homeassistant/outputs.tf b/products/homeassistant/outputs.tf
new file mode 100644
index 0000000..69672b7
--- /dev/null
+++ b/products/homeassistant/outputs.tf
@@ -0,0 +1,3 @@
+output "docker_service" {
+  value = module.homeassistant.docker_service
+}
\ No newline at end of file
diff --git a/products/homeassistant/terraform.tf b/products/homeassistant/terraform.tf
new file mode 100644
index 0000000..4496f20
--- /dev/null
+++ b/products/homeassistant/terraform.tf
@@ -0,0 +1,15 @@
+terraform {
+  required_version = "~> 1.6"
+  required_providers {
+    docker = {
+      source  = "kreuzwerker/docker"
+      version = "~>3.0"
+    }
+    random = {
+      source  = "hashicorp/random"
+      version = "~> 3.5"
+    }
+  }
+}
+
+
diff --git a/products/minio/inputs.tf b/products/minio/inputs.tf
new file mode 100644
index 0000000..7dcd8a6
--- /dev/null
+++ b/products/minio/inputs.tf
@@ -0,0 +1,24 @@
+variable "stack_name" {
+  default     = "mitmproxy"
+  type        = string
+  description = "The name of the stack to create."
+}
+variable "networks" {
+  type        = list(string)
+  default     = []
+  description = "A list of network names to attach the service to."
+}
+
+variable "traefik" {
+  default = null
+  type = object({
+    domain = string
+    port   = optional(number)
+  })
+  description = "Whether to enable traefik for the service."
+}
+variable "placement_constraints" {
+  default     = []
+  type        = list(string)
+  description = "Docker Swarm placement constraints"
+}
diff --git a/products/minio/minio.tf b/products/minio/minio.tf
new file mode 100644
index 0000000..bb09113
--- /dev/null
+++ b/products/minio/minio.tf
@@ -0,0 +1,64 @@
+resource "random_pet" "minio_admin_user" {
+  length    = 2
+  separator = ""
+}
+resource "random_password" "minio_admin_password" {
+  length  = 32
+  special = false
+}
+variable "domain" {
+  type        = string
+  description = "The domain to use for the service."
+}
+variable "mounts" {
+  type        = map(string)
+  default     = {}
+  description = "A map of host paths to container paths to mount. The key is the host path, and the value is the container path."
+}
+module "minio" {
+  source       = "../../docker/service"
+  stack_name   = "minio"
+  service_name = "minio"
+  image        = "quay.io/minio/minio:latest"
+  command      = ["minio", "server", "/data", ]
+  environment_variables = {
+    MINIO_ADDRESS              = "0.0.0.0:9000"
+    MINIO_CONSOLE_ADDRESS      = "0.0.0.0:9001"
+    MINIO_ROOT_USER            = random_pet.minio_admin_user.id
+    MINIO_ROOT_PASSWORD        = random_password.minio_admin_password.result
+    MINIO_SERVER_URL           = "https://s3.grey.ooo"
+    MINIO_BROWSER_REDIRECT_URL = "https://s3.grey.ooo/ui/"
+    MINIO_BROWSER_REDIRECT     = true
+    MINIO_API_ROOT_ACCESS      = "on"
+  }
+  mounts                = var.mounts
+  networks              = concat(["loadbalancer-traefik"], var.networks)
+  placement_constraints = var.placement_constraints
+  labels = {
+    "traefik.enable" = "true"
+
+    // API redirect
+    "traefik.http.routers.minio_api.rule" = "Host(`${var.domain}`) && !PathPrefix(`/ui`)"
+    #"traefik.http.routers.minio_api.service"                   = "minio_api"
+    "traefik.http.routers.minio_api.entrypoints"               = "websecure"
+    "traefik.http.routers.minio_api.tls.certresolver"          = "default"
+    "traefik.http.services.minio_api.loadbalancer.server.port" = "9000"
+
+    // UI redirect
+    "traefik.http.routers.minio_ui.rule" = "Host(`${var.domain}`) && PathPrefix(`/ui`)"
+    #"traefik.http.routers.minio_ui.service"                   = "minio_ui"
+    "traefik.http.routers.minio_ui.entrypoints"               = "websecure"
+    "traefik.http.routers.minio_ui.tls.certresolver"          = "default"
+    "traefik.http.services.minio_ui.loadbalancer.server.port" = "9001"
+  }
+}
+
+output "minio" {
+  value = {
+    endpoint = "https://${var.domain}/ui/"
+    auth = {
+      username = module.minio.docker_service.task_spec[0].container_spec[0].env.MINIO_ROOT_USER
+      password = nonsensitive(module.minio.docker_service.task_spec[0].container_spec[0].env.MINIO_ROOT_PASSWORD)
+    }
+  }
+}
diff --git a/products/minio/outputs.tf b/products/minio/outputs.tf
new file mode 100644
index 0000000..d5d704b
--- /dev/null
+++ b/products/minio/outputs.tf
@@ -0,0 +1,3 @@
+output "docker_service" {
+  value = module.minio.docker_service
+}
\ No newline at end of file
diff --git a/products/minio/terraform.tf b/products/minio/terraform.tf
new file mode 100644
index 0000000..4496f20
--- /dev/null
+++ b/products/minio/terraform.tf
@@ -0,0 +1,15 @@
+terraform {
+  required_version = "~> 1.6"
+  required_providers {
+    docker = {
+      source  = "kreuzwerker/docker"
+      version = "~>3.0"
+    }
+    random = {
+      source  = "hashicorp/random"
+      version = "~> 3.5"
+    }
+  }
+}
+
+
diff --git a/products/mitmproxy/inputs.tf b/products/mitmproxy/inputs.tf
new file mode 100644
index 0000000..822565e
--- /dev/null
+++ b/products/mitmproxy/inputs.tf
@@ -0,0 +1,25 @@
+variable "stack_name" {
+  default     = "mitmproxy"
+  type        = string
+  description = "The name of the stack to create."
+}
+
+variable "networks" {
+  type        = list(string)
+  default     = []
+  description = "A list of network names to attach the service to."
+}
+
+variable "traefik" {
+  default = null
+  type = object({
+    domain = string
+    port   = optional(number, 8081)
+  })
+  description = "Whether to enable traefik for the service."
+}
+variable "placement_constraints" {
+  default     = []
+  type        = list(string)
+  description = "Docker Swarm placement constraints"
+}
diff --git a/products/mitmproxy/mitmproxy.tf b/products/mitmproxy/mitmproxy.tf
new file mode 100644
index 0000000..fc660a2
--- /dev/null
+++ b/products/mitmproxy/mitmproxy.tf
@@ -0,0 +1,30 @@
+module "mitmproxy" {
+  source       = "../../docker/service"
+  stack_name   = var.stack_name
+  service_name = "mitmproxy"
+  networks     = var.networks
+  image        = "ghcr.io/benzine-framework/mitmproxy"
+  command = [
+    "mitmweb",
+    "--web-host", "0.0.0.0",
+    "--web-port", "8081",
+    #"--listen-host", "0.0.0.0",
+    #"--listen-port", "8080",
+    #"--ssl-insecure",
+  ]
+  healthcheck           = ["CMD-SHELL", " curl -I http://localhost:8081 || exit 1"]
+  placement_constraints = var.placement_constraints
+  traefik               = var.traefik
+  ports = [
+    {
+      protocol  = "tcp"
+      container = 8080
+      host      = 4080
+    },
+    {
+      protocol  = "tcp"
+      container = 8081
+      host      = 4081
+    }
+  ]
+}
\ No newline at end of file
diff --git a/products/mitmproxy/outputs.tf b/products/mitmproxy/outputs.tf
new file mode 100644
index 0000000..613b9be
--- /dev/null
+++ b/products/mitmproxy/outputs.tf
@@ -0,0 +1,3 @@
+output "docker_service" {
+  value = module.mitmproxy.docker_service
+}
\ No newline at end of file
diff --git a/products/mitmproxy/terraform.tf b/products/mitmproxy/terraform.tf
new file mode 100644
index 0000000..0814b57
--- /dev/null
+++ b/products/mitmproxy/terraform.tf
@@ -0,0 +1,11 @@
+terraform {
+  required_version = "~> 1.6"
+  required_providers {
+    docker = {
+      source  = "kreuzwerker/docker"
+      version = "~>3.0"
+    }
+  }
+}
+
+
diff --git a/products/pgbackweb/inputs.tf b/products/pgbackweb/inputs.tf
index 9e74469..0e059ba 100644
--- a/products/pgbackweb/inputs.tf
+++ b/products/pgbackweb/inputs.tf
@@ -26,4 +26,8 @@ variable "placement_constraints" {
 variable "networks" {
   type    = list(string)
   default = []
+}
+variable "domain" {
+  type        = string
+  description = "The domain to use for the service's traefik configuration."
 }
\ No newline at end of file
diff --git a/products/pgbackweb/pgbackweb.tf b/products/pgbackweb/pgbackweb.tf
index 3e0a6ee..3b18060 100644
--- a/products/pgbackweb/pgbackweb.tf
+++ b/products/pgbackweb/pgbackweb.tf
@@ -17,6 +17,7 @@ module "pgbackweb" {
   service_name          = var.service_name
   networks              = concat([module.network.network], var.networks)
   placement_constraints = var.placement_constraints
+  traefik               = { domain = var.domain }
 }
 module "postgres" {
   source                = "../postgres"
diff --git a/products/portainer/ui/inputs.tf b/products/portainer/ui/inputs.tf
index 2e5607c..066bb21 100644
--- a/products/portainer/ui/inputs.tf
+++ b/products/portainer/ui/inputs.tf
@@ -1,16 +1,33 @@
-variable "docker" {
-  type = object({
-    name       = string
-    stack_name = optional(string)
-    networks = list(object({
-      name = string
-      id   = string
-    }))
-  })
+variable "stack_name" {
+  default     = "loadbalancer"
+  type        = string
+  description = "The name of the stack to create."
 }
-variable "portainer" {
+variable "traefik" {
+  default = null
   type = object({
-    version = string
-    logo    = optional(string)
+    domain = string
+    port   = optional(number, 9000)
   })
+  description = "Whether to enable traefik for the service."
 }
+variable "placement_constraints" {
+  default     = []
+  type        = list(string)
+  description = "Docker Swarm placement constraints"
+}
+
+variable "portainer_version" {
+  default     = "sts"
+  type        = string
+  description = "The version of the portainer image to use."
+}
+variable "portainer_logo" {
+  default     = null
+  type        = string
+  description = "The URL of the logo to use for the portainer service."
+}
+variable "should_mount_local_docker_socket" {
+  type    = bool
+  default = false
+}
\ No newline at end of file
diff --git a/products/portainer/ui/output.tf b/products/portainer/ui/output.tf
index 7853309..e05921f 100644
--- a/products/portainer/ui/output.tf
+++ b/products/portainer/ui/output.tf
@@ -4,6 +4,6 @@ output "portainer" {
       username = "admin" # Sorry, this is hardcoded in the portainer image
       password = nonsensitive(random_password.password.result)
     }
-    service_name = docker_service.portainer.name
+    service_name = module.portainer.docker_service.name
   }
 }
diff --git a/products/portainer/ui/ui.tf b/products/portainer/ui/ui.tf
index b4e2fd5..1ee86dd 100644
--- a/products/portainer/ui/ui.tf
+++ b/products/portainer/ui/ui.tf
@@ -8,99 +8,31 @@ resource "htpasswd_password" "hash" {
   password = random_password.password.result
   salt     = random_password.salt.result
 }
-data "docker_registry_image" "portainer_app" {
-  name = "portainer/portainer-ce:${var.portainer.version}"
+module "vol_portainer" {
+  source      = "../../../docker/volume"
+  stack_name  = var.stack_name
+  volume_name = "portainer"
 }
-resource "docker_volume" "portainer" {
-  name = var.docker.name
-}
-resource "docker_service" "portainer" {
-  name = var.docker.name
-  mode {
-    replicated {
-      replicas = 1
-    }
-  }
-  task_spec {
-    container_spec {
-      image = "${data.docker_registry_image.portainer_app.name}@${data.docker_registry_image.portainer_app.sha256_digest}"
-      command = [
-        "/portainer",
-        //"--edge-compute",
-        "--logo", coalesce(var.portainer.logo),
-        "--admin-password", htpasswd_password.hash.bcrypt,
-      ]
-      #mounts {
-      #  target    = "/data"
-      #  source    = "/portainer"
-      #  read_only = false
-      #  type      = "bind"
-      #}
-      mounts {
-        target    = "/data"
-        source    = docker_volume.portainer.name
-        type      = "volume"
-        read_only = false
-      }
-      #mounts {
-      #  target    = "/var/run/docker.sock"
-      #  source    = "/var/run/docker.sock"
-      #  read_only = false
-      #  type      = "bind"
-      #}
-      labels {
-        label = "com.docker.stack.namespace"
-        value = var.docker.stack_name
-      }
-    }
-    dynamic "networks_advanced" {
-      for_each = var.docker.networks
-      content {
-        name = networks_advanced.value.id
-      }
-    }
-    restart_policy {
-      condition    = "on-failure"
-      delay        = "3s"
-      max_attempts = 4
-      window       = "10s"
-    }
-    placement {
-      constraints = [
-        "node.role == manager",
-        "node.platform.os == linux",
-      ]
-    }
-  }
-  #endpoint_spec {
-  #  ports {
-  #    target_port    = 9000
-  #    publish_mode   = "ingress"
-  #    published_port = 9000
-  #  }
-  #  ports {
-  #    target_port    = 8000
-  #    publish_mode   = "ingress"
-  #    published_port = 8000
-  #  }
-  #}
-  update_config {
-    # Portainer gets super fuckin' upset if you start a second instance while the first is holding the db lock
-    order = "stop-first"
-  }
-
-  labels {
-    label = "com.docker.stack.namespace"
-    value = var.docker.stack_name
-  }
-  labels {
-    label = "com.docker.stack.image"
-    value = replace(data.docker_registry_image.portainer_app.name, "/:.*/", "")
-  }
-  lifecycle {
-    ignore_changes = [
-      # MB: This is a hack because terraform keeps detecting a "change" in the placement->platform constraint that doesn't exist.
-      task_spec[0].placement[0].platforms
-    ]
-  }
+module "portainer" {
+  source       = "../../../docker/service"
+  stack_name   = var.stack_name
+  service_name = "portainer"
+  image        = "portainer/portainer-ce:${var.portainer_version}"
+  command = [
+    "/portainer",
+    //"--edge-compute",
+    "--logo", coalesce(var.portainer_logo),
+    "--admin-password", htpasswd_password.hash.bcrypt,
+  ]
+  remote_volumes = {
+    "/data" = module.vol_portainer.volume
+  }
+  traefik     = var.traefik
+  mounts      = var.should_mount_local_docker_socket ? { "/var/run/docker.sock" = "/var/run/docker.sock" } : {}
+  networks    = ["loadbalancer-traefik"]
+  start_first = false
+  placement_constraints = concat([
+    "node.role == manager",
+    "node.platform.os == linux",
+  ], var.placement_constraints)
 }
diff --git a/products/smokeping/inputs.tf b/products/smokeping/inputs.tf
new file mode 100644
index 0000000..af0c2c2
--- /dev/null
+++ b/products/smokeping/inputs.tf
@@ -0,0 +1,18 @@
+variable "timezone" {
+  type        = string
+  description = "The timezone to use for the service."
+  default     = "Europe/London"
+}
+variable "traefik" {
+  default = null
+  type = object({
+    domain = string
+    port   = optional(number)
+  })
+  description = "Whether to enable traefik for the service."
+}
+variable "placement_constraints" {
+  default     = []
+  type        = list(string)
+  description = "Docker Swarm placement constraints"
+}
diff --git a/products/smokeping/outputs.tf b/products/smokeping/outputs.tf
new file mode 100644
index 0000000..c594383
--- /dev/null
+++ b/products/smokeping/outputs.tf
@@ -0,0 +1,3 @@
+output "docker_service" {
+  value = module.smokeping.docker_service
+}
\ No newline at end of file
diff --git a/products/smokeping/smokeping.tf b/products/smokeping/smokeping.tf
new file mode 100644
index 0000000..fdefda9
--- /dev/null
+++ b/products/smokeping/smokeping.tf
@@ -0,0 +1,15 @@
+module "smokeping" {
+  source       = "../../docker/service"
+  stack_name   = "smokeping"
+  service_name = "smokeping"
+  image        = "linuxserver/smokeping:latest"
+  volumes      = { "smokeping" = "/data" }
+  environment_variables = {
+    PUID = 1000
+    PGID = 1000
+    TZ   = var.timezone
+  }
+  traefik               = var.traefik
+  networks              = ["loadbalancer-traefik"]
+  placement_constraints = var.placement_constraints
+}
\ No newline at end of file
diff --git a/products/smokeping/terraform.tf b/products/smokeping/terraform.tf
new file mode 100644
index 0000000..263a49f
--- /dev/null
+++ b/products/smokeping/terraform.tf
@@ -0,0 +1,16 @@
+terraform {
+  required_version = "~> 1.6"
+
+  required_providers {
+    docker = {
+      source  = "kreuzwerker/docker"
+      version = "~>3.0"
+    }
+    random = {
+      source  = "hashicorp/random"
+      version = "~> 3.5"
+    }
+  }
+}
+
+
diff --git a/products/statping/inputs.tf b/products/statping/inputs.tf
index cec6296..65355e3 100644
--- a/products/statping/inputs.tf
+++ b/products/statping/inputs.tf
@@ -42,3 +42,11 @@ variable "extra_environment_variables" {
   default     = {}
   description = "Extra environment variables to pass to the service."
 }
+variable "traefik" {
+  default = null
+  type = object({
+    domain = string
+    port   = optional(number)
+  })
+  description = "Whether to enable traefik for the service."
+}
\ No newline at end of file
diff --git a/products/statping/nginx.tf b/products/statping/nginx.tf
deleted file mode 100644
index 64ce7cd..0000000
--- a/products/statping/nginx.tf
+++ /dev/null
@@ -1,24 +0,0 @@
-variable "nginx_hostname" {
-  type    = string
-  default = null
-}
-variable "acme_certificate" {
-  type = object({
-    private_key_pem = string
-    certificate_pem = string
-    issuer_pem      = string
-  })
-  default = null
-}
-module "nginx_config" {
-  count         = var.nginx_hostname != null ? 1 : 0
-  source        = "../nginx/site-available"
-  service_name  = module.service.service_name
-  hostname      = var.nginx_hostname
-  upstream_host = "${module.service.service_name}:8080"
-  config_prefix = module.service.service_name
-  certificate   = var.acme_certificate
-}
-output "nginx_files" {
-  value = var.nginx_hostname != null ? module.nginx_config[0].files : []
-}
\ No newline at end of file
diff --git a/products/statping/outputs.tf b/products/statping/outputs.tf
index 15a2e8f..26cad0e 100644
--- a/products/statping/outputs.tf
+++ b/products/statping/outputs.tf
@@ -7,7 +7,7 @@ output "statping" {
       port     = module.postgres.ports[0]
     }
     statping = {
-      instance = var.nginx_hostname != null ? "https://${var.nginx_hostname}" : null
+      instance = try("https://${var.traefik.domain}", "unknown")
     }
   }
 }
diff --git a/products/statping/statping.tf b/products/statping/statping.tf
index dd7ce47..158eecf 100644
--- a/products/statping/statping.tf
+++ b/products/statping/statping.tf
@@ -15,7 +15,7 @@ module "service" {
   image        = "${var.statping_image}:${var.statping_version}"
   stack_name   = var.stack_name
   service_name = "statping"
-  networks     = concat([module.network.network, ], var.networks)
+  networks     = concat([module.network.network, "loadbalancer-traefik"], var.networks)
   environment_variables = merge({
     VIRTUAL_HOST = "localhost"
     VIRTUAL_PORT = "8080"
@@ -29,4 +29,5 @@ module "service" {
   }, var.extra_environment_variables)
   placement_constraints = var.placement_constraints
   dns_nameservers       = var.dns_nameservers
+  traefik               = var.traefik
 }
diff --git a/products/traefik/docker-socket-proxy.tf b/products/traefik/docker-socket-proxy.tf
new file mode 100644
index 0000000..b020b44
--- /dev/null
+++ b/products/traefik/docker-socket-proxy.tf
@@ -0,0 +1,5 @@
+module "docker_socket_proxy" {
+  source                = "../../docker/socket-proxy"
+  stack_name            = var.stack_name
+  placement_constraints = var.placement_constraints
+}
\ No newline at end of file
diff --git a/products/traefik/hello.tf b/products/traefik/hello.tf
new file mode 100644
index 0000000..6769ff8
--- /dev/null
+++ b/products/traefik/hello.tf
@@ -0,0 +1,14 @@
+module "traefik_hello" {
+  count                 = var.hello_service_domain != null ? 1 : 0
+  source                = "../../docker/service"
+  stack_name            = var.stack_name
+  service_name          = "hello"
+  image                 = "traefik/whoami"
+  parallelism           = 3
+  placement_constraints = var.placement_constraints
+  networks              = [module.traefik_network.network, ]
+  traefik = {
+    domain = var.hello_service_domain
+    port   = 80
+  }
+}
\ No newline at end of file
diff --git a/products/traefik/inputs.tf b/products/traefik/inputs.tf
new file mode 100644
index 0000000..d77744f
--- /dev/null
+++ b/products/traefik/inputs.tf
@@ -0,0 +1,43 @@
+variable "stack_name" {
+  default     = "loadbalancer"
+  type        = string
+  description = "The name of the stack to create."
+}
+
+variable "placement_constraints" {
+  default     = []
+  type        = list(string)
+  description = "Docker Swarm placement constraints"
+}
+variable "acme_use_staging" {
+  type        = bool
+  default     = false
+  description = "Whether to use the Let's Encrypt staging server."
+}
+variable "acme_email" {
+  description = "The email address to use for the ACME certificate."
+  type        = string
+}
+variable "traefik_service_domain" {
+  type    = string
+  default = null
+}
+variable "hello_service_domain" {
+  type    = string
+  default = null
+}
+variable "log_level" {
+  type        = string
+  default     = "INFO"
+  description = "The log level to use for traefik."
+}
+variable "access_log" {
+  type        = bool
+  default     = false
+  description = "Whether to enable access logging."
+}
+variable "redirect_to_ssl" {
+  type        = bool
+  default     = true
+  description = "Whether to redirect HTTP to HTTPS."
+}
\ No newline at end of file
diff --git a/products/traefik/network.tf b/products/traefik/network.tf
new file mode 100644
index 0000000..064fa64
--- /dev/null
+++ b/products/traefik/network.tf
@@ -0,0 +1,6 @@
+module "traefik_network" {
+  source       = "../../docker/network"
+  stack_name   = var.stack_name
+  network_name = "traefik"
+  subnet       = "172.16.0.0/22"
+}
diff --git a/products/traefik/outputs.tf b/products/traefik/outputs.tf
new file mode 100644
index 0000000..06c1a95
--- /dev/null
+++ b/products/traefik/outputs.tf
@@ -0,0 +1,6 @@
+output "docker_service" {
+  value = module.traefik.docker_service
+}
+output "docker_network" {
+  value = module.traefik_network
+}
\ No newline at end of file
diff --git a/products/traefik/terraform.tf b/products/traefik/terraform.tf
new file mode 100644
index 0000000..ed365fe
--- /dev/null
+++ b/products/traefik/terraform.tf
@@ -0,0 +1,13 @@
+terraform {
+  required_version = "~> 1.6"
+  required_providers {
+    docker = {
+      source  = "kreuzwerker/docker"
+      version = "~>3.0"
+    }
+    random = {
+      source  = "hashicorp/random"
+      version = "~> 3.5"
+    }
+  }
+}
\ No newline at end of file
diff --git a/products/traefik/traefik.tf b/products/traefik/traefik.tf
new file mode 100644
index 0000000..101e6a8
--- /dev/null
+++ b/products/traefik/traefik.tf
@@ -0,0 +1,69 @@
+module "traefik_certs_volume" {
+  source      = "../../docker/volume"
+  stack_name  = var.stack_name
+  volume_name = "traefik_certs"
+}
+module "traefik" {
+  source                = "../../docker/service"
+  depends_on            = [module.docker_socket_proxy]
+  stack_name            = var.stack_name
+  service_name          = "traefik"
+  image                 = "traefik:v3.2"
+  networks              = [module.traefik_network.network, module.docker_socket_proxy.network, ]
+  mounts                = { "/goliath/letsencrypt" = "/certs" }
+  placement_constraints = var.placement_constraints
+  converge_enable       = false // @todo add healthcheck
+  command = [
+    "/usr/local/bin/traefik",
+    "--api.insecure=true",
+    "--api.dashboard=true",
+    "--log.level=${var.log_level}",
+    "--accesslog=${var.access_log ? "true" : "false"}",
+    "--ping=true",
+
+    # Confirm Docker Provider
+    "--providers.docker=true",
+    "--providers.docker.exposedbydefault=false",
+    "--providers.docker.network=${module.traefik_network.name}",
+    "--providers.docker.endpoint=http://${module.docker_socket_proxy.docker_service.name}:2375",
+
+    # Confirm Swarm Provider
+    "--providers.swarm=true",
+    "--providers.swarm.exposedByDefault=false",
+    "--providers.swarm.network=${module.traefik_network.name}",
+    "--providers.swarm.endpoint=http://${module.docker_socket_proxy.docker_service.name}:2375",
+
+    # Configure HTTP and redirect to HTTPS
+    "--entrypoints.web.address=:80",
+
+    # Configure HTTPS
+    "--entrypoints.websecure.address=:443",
+    var.redirect_to_ssl ? "--entrypoints.web.http.redirections.entrypoint.to=websecure" : "",
+    var.redirect_to_ssl ? "--entrypoints.web.http.redirections.entrypoint.scheme=https" : "",
+
+    # Configure the acme provider
+    "--certificatesresolvers.default.acme.tlschallenge=true",
+    var.acme_use_staging ? "--certificatesresolvers.default.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" : "",
+    "--certificatesresolvers.default.acme.email=${var.acme_email}",
+    "--certificatesresolvers.default.acme.storage=/certs/acme.json",
+  ]
+  traefik = var.traefik_service_domain != null ? {
+    domain = var.traefik_service_domain
+    port   = 8080
+  } : null
+  ports = [
+    {
+      host      = 80
+      container = 80
+    },
+    {
+      host      = 443
+      container = 443
+    },
+    {
+      host      = 8080
+      container = 8080
+    },
+  ]
+
+}
diff --git a/products/watchtower/outputs.tf b/products/watchtower/outputs.tf
new file mode 100644
index 0000000..366cd6f
--- /dev/null
+++ b/products/watchtower/outputs.tf
@@ -0,0 +1,3 @@
+output "docker_service" {
+  value = module.watchtower.docker_service
+}