diff --git a/modules/20-services-apps/affine/.env.example b/modules/20-services-apps/affine/.env.example new file mode 100644 index 0000000..c4b2427 --- /dev/null +++ b/modules/20-services-apps/affine/.env.example @@ -0,0 +1,17 @@ +# Affine Configuration +AFFINE_REVISION=canary +PORT=3010 + +AFFINE_SERVER_HTTPS=true +AFFINE_SERVER_HOST=affine.yourdomain.com +AFFINE_SERVER_NAME='AFFiNE Selfhosted' + +# Database Configuration +DB_USERNAME=affine +DB_PASSWORD=change_this_password +DB_DATABASE=affine + +# R2 Configuration +R2_OBJECT_STORAGE_ACCOUNT_ID= +R2_OBJECT_STORAGE_ACCESS_KEY_ID= +R2_OBJECT_STORAGE_SECRET_ACCESS_KEY= diff --git a/modules/20-services-apps/affine/README.md b/modules/20-services-apps/affine/README.md new file mode 100644 index 0000000..a8294b4 --- /dev/null +++ b/modules/20-services-apps/affine/README.md @@ -0,0 +1,121 @@ +# AFFiNE Module + +This module deploys [AFFiNE](https://affine.pro/), a privacy-first, local-first, note-taking and knowledge base application, as Docker containers in the homelab environment. + +## Overview + +The AFFiNE module: + +- Deploys four Docker containers: + - `affine_server`: The main AFFiNE application server + - `affine_migration_job`: A container that runs pre-deployment migrations + - `affine_postgres`: A PostgreSQL (pgvector) database backend + - `affine_redis`: A Redis instance for caching and temporary data +- Creates a dedicated Docker network (`affine-network`) for container communication +- Persists data to volumes on the host +- Provides service definition for integration with networking modules + +## Usage + +```hcl +module "affine" { + source = "./modules/20-services-apps/affine" + volume_path = "/path/to/volumes/affine" + networks = ["homelab-network"] +} +``` + +## Variables + +| Variable | Description | Type | Default | +| ------------- | -------------------------------------------------------------- | -------------- | ---------- | +| `image_tag` | Tag of the AFFiNE image to use | `string` | `"stable"` | +| `volume_path` | Host path for AFFiNE and database data volumes | `string` | - | +| `networks` | List of additional networks to which AFFiNE should be attached | `list(string)` | `[]` | + +## Outputs + +| Output | Description | +| -------------------- | ---------------------------------------------------------- | +| `service_definition` | Service definition for integration with networking modules | + +## Service Definition + +This module outputs a service definition that is used by the networking modules to expose the service. + +```hcl +{ + name = "affine_server" + primary_port = 3010 + endpoint = "http://affine_server:3010" + subdomains = ["affine"] + publish_via = "tunnel" # Only publish through Cloudflare tunnel +} +``` + +## Environment Variables + +AFFiNE requires several environment variables to function properly. These are stored in a `.env` file in the module directory and read using the `dotenv` Terraform provider: + +- Database configuration: + + - `DB_USERNAME`: PostgreSQL user + - `DB_PASSWORD`: PostgreSQL password + - `DB_DATABASE`: Database name (defaults to "affine") + +- AFFiNE configuration: + + - `AFFINE_REVISION`: Version of AFFiNE to use ("stable" or "canary") (defaults to "canary") + - `PORT`: External port for the AFFiNE server (defaults to 3010) + - `AFFINE_SERVER_HTTPS`: Whether to use HTTPS (defaults to "true") + - `AFFINE_SERVER_HOST`: Hostname for the AFFiNE server + - `AFFINE_SERVER_NAME`: Name for the AFFiNE server (defaults to "AFFiNE Selfhosted") + +- Cloudflare R2 configuration: + - `R2_OBJECT_STORAGE_ACCOUNT_ID`: Cloudflare R2 account ID + - `R2_OBJECT_STORAGE_ACCESS_KEY_ID`: Cloudflare R2 access key ID + - `R2_OBJECT_STORAGE_SECRET_ACCESS_KEY`: Cloudflare R2 secret access key + +## Data Persistence + +AFFiNE stores its data in three main volumes: + +1. AFFiNE application data: `/root/.affine/storage` in the container, mapped to `${volume_path}/self-host/storage` on the host +2. AFFiNE configuration: `/root/.affine/config` in the container, mapped to `${volume_path}/self-host/config` on the host +3. PostgreSQL data: `/var/lib/postgresql/data` in the container, mapped to `${volume_path}/self-host/postgres/pgdata` on the host + +## Networking + +The module creates a dedicated Docker network named `affine-network` for communication between the AFFiNE components. The AFFiNE server container is also attached to any additional networks specified in the `networks` variable, allowing it to communicate with other services in the homelab. + +## Dependencies + +The AFFiNE containers have the following dependencies: + +- The main `affine_server` depends on PostgreSQL, Redis, and the migration job +- The migration job depends on PostgreSQL and Redis +- Both PostgreSQL and Redis use healthchecks to ensure they're ready before dependent services start + +## Integration with Networking Modules + +This service is configured to be exposed through the Caddy reverse proxy, set by `publish_via = "reverse_proxy"`. + +## Example Integration in Main Configuration + +```hcl +module "affine" { + source = "./modules/20-services-apps/affine" + volume_path = module.system_globals.volume_host + networks = [module.services.homelab_docker_network_name] +} + +# The service definition is automatically included in the services output +module "services" { + source = "./modules/services" + # ... + service_definitions = [ + module.affine.service_definition, + # Other service definitions + ] +} +``` diff --git a/modules/20-services-apps/affine/main.tf b/modules/20-services-apps/affine/main.tf new file mode 100644 index 0000000..b4f92d8 --- /dev/null +++ b/modules/20-services-apps/affine/main.tf @@ -0,0 +1,198 @@ +terraform { + required_providers { + dotenv = { + source = "germanbrew/dotenv" + } + } +} + +variable "image_tag" { + description = "The tag for the affine container image" + type = string + default = "stable" +} + +variable "volume_path" { + description = "Base directory for volumes" + type = string +} + +variable "networks" { + description = "List of networks to which the container should be attached" + type = list(string) + default = [] +} + +module "smtp" { + source = "../../00-globals/smtp" +} + +locals { + container_name = "affine-server" + migration_name = "affine-migration-job" + redis_name = "affine-redis" + postgres_name = "affine-postgres" + affine_image = "ghcr.io/yurisasc/affine-graphql" + postgres_image = "pgvector/pgvector" + redis_image = "redis" + affine_tag = provider::dotenv::get_by_key("AFFINE_REVISION", local.env_file) + postgres_tag = "pg16" + redis_tag = "latest" + monitoring = true + env_file = "${path.module}/.env" + affine_internal_port = 3010 + + # Define volumes + affine_volumes = [ + { + host_path = "${var.volume_path}/self-host/storage" + container_path = "/root/.affine/storage" + read_only = false + }, + { + host_path = "${var.volume_path}/self-host/config" + container_path = "/root/.affine/config" + read_only = false + } + ] + + migration_volumes = [ + { + host_path = "${var.volume_path}/self-host/storage" + container_path = "/root/.affine/storage" + read_only = false + }, + { + host_path = "${var.volume_path}/self-host/config" + container_path = "/root/.affine/config" + read_only = false + } + ] + + postgres_volumes = [ + { + host_path = "${var.volume_path}/self-host/postgres/pgdata" + container_path = "/var/lib/postgresql/data" + read_only = false + } + ] + + # Environment variables for postgres + postgres_env_vars = { + POSTGRES_USER = provider::dotenv::get_by_key("DB_USERNAME", local.env_file) + POSTGRES_PASSWORD = provider::dotenv::get_by_key("DB_PASSWORD", local.env_file) + POSTGRES_DB = provider::dotenv::get_by_key("DB_DATABASE", local.env_file) + POSTGRES_INITDB_ARGS = "--data-checksums" + POSTGRES_HOST_AUTH_METHOD = "trust" + } + + # Environment variables for AFFiNE + affine_env_vars = { + REDIS_SERVER_HOST = local.redis_name + DATABASE_URL = "postgresql://${provider::dotenv::get_by_key("DB_USERNAME", local.env_file)}:${provider::dotenv::get_by_key("DB_PASSWORD", local.env_file)}@${local.postgres_name}:5432/${provider::dotenv::get_by_key("DB_DATABASE", local.env_file)}" + AFFINE_INDEXER_ENABLED = "false" + AFFINE_SERVER_HTTPS = provider::dotenv::get_by_key("AFFINE_SERVER_HTTPS", local.env_file) + AFFINE_SERVER_HOST = provider::dotenv::get_by_key("AFFINE_SERVER_HOST", local.env_file) + AFFINE_SERVER_NAME = provider::dotenv::get_by_key("AFFINE_SERVER_NAME", local.env_file) + PORT = provider::dotenv::get_by_key("PORT", 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) + DB_DATABASE = provider::dotenv::get_by_key("DB_DATABASE", local.env_file) + MAILER_HOST = module.smtp.mail_host + MAILER_PORT = module.smtp.mail_port + MAILER_USER = module.smtp.mail_username + MAILER_PASSWORD = module.smtp.mail_password + R2_OBJECT_STORAGE_ACCOUNT_ID = provider::dotenv::get_by_key("R2_OBJECT_STORAGE_ACCOUNT_ID", local.env_file) + R2_OBJECT_STORAGE_ACCESS_KEY_ID = provider::dotenv::get_by_key("R2_OBJECT_STORAGE_ACCESS_KEY_ID", local.env_file) + R2_OBJECT_STORAGE_SECRET_ACCESS_KEY = provider::dotenv::get_by_key("R2_OBJECT_STORAGE_SECRET_ACCESS_KEY", local.env_file) + } + + # Healthcheck configuration for Redis + redis_healthcheck = { + test = ["CMD", "redis-cli", "--raw", "incr", "ping"] + interval = "10s" + timeout = "5s" + retries = 5 + start_period = "5s" + } + + # Healthcheck configuration for Postgres + postgres_healthcheck = { + test = ["CMD", "pg_isready", "-U", provider::dotenv::get_by_key("DB_USERNAME", local.env_file), "-d", provider::dotenv::get_by_key("DB_DATABASE", local.env_file)] + interval = "10s" + timeout = "5s" + retries = 5 + start_period = "5s" + } +} + +module "affine_network" { + source = "../../01-networking/docker-network" + name = "affine-network" + subnet = "11.100.0.0/16" + driver = "bridge" +} + +# Create the Redis container +module "redis" { + source = "../../10-services-generic/docker-service" + container_name = local.redis_name + image = local.redis_image + tag = local.redis_tag + networks = [module.affine_network.name] + monitoring = local.monitoring + healthcheck = local.redis_healthcheck +} + +# Create the PostgreSQL container +module "postgres" { + source = "../../10-services-generic/docker-service" + container_name = local.postgres_name + image = local.postgres_image + tag = local.postgres_tag + volumes = local.postgres_volumes + env_vars = local.postgres_env_vars + networks = [module.affine_network.name] + monitoring = local.monitoring + healthcheck = local.postgres_healthcheck +} + +# Create the migration job container +module "migration" { + source = "../../10-services-generic/docker-service" + container_name = local.migration_name + image = local.affine_image + tag = local.affine_tag + volumes = local.migration_volumes + env_vars = local.affine_env_vars + command = ["sh", "-c", "node ./scripts/self-host-predeploy.js"] + networks = [module.affine_network.name] + monitoring = local.monitoring + depends_on = [module.postgres, module.redis] + restart_policy = "no" +} + +# Create the affine container +module "affine" { + source = "../../10-services-generic/docker-service" + container_name = local.container_name + image = local.affine_image + tag = local.affine_tag + volumes = local.affine_volumes + env_vars = local.affine_env_vars + networks = concat([module.affine_network.name], var.networks) + monitoring = local.monitoring + depends_on = [module.postgres, module.redis, module.migration] +} + +output "service_definition" { + description = "General service definition with optional ingress configuration" + value = { + name = local.container_name + primary_port = local.affine_internal_port + endpoint = "http://${local.container_name}:${local.affine_internal_port}" + subdomains = ["notes"] + publish_via = "reverse_proxy" + proxied = false + } +} diff --git a/services/main.tf b/services/main.tf index d8cea4d..64b2399 100644 --- a/services/main.tf +++ b/services/main.tf @@ -24,6 +24,12 @@ module "actualbudget" { networks = [module.homelab_docker_network.name] } +module "affine" { + source = "${local.module_dir}/20-services-apps/affine" + volume_path = "${local.volume_host}/affine" + networks = [module.homelab_docker_network.name] +} + module "emulatorjs" { source = "${local.module_dir}/20-services-apps/emulatorjs" volume_path = "${local.volume_host}/emulatorjs" diff --git a/services/outputs.tf b/services/outputs.tf index 30f0a90..b24e26a 100644 --- a/services/outputs.tf +++ b/services/outputs.tf @@ -5,6 +5,7 @@ output "service_definitions" { description = "Service definitions for all services" value = [ module.actualbudget.service_definition, + module.affine.service_definition, module.emulatorjs.service_definition, module.linkwarden.service_definition, module.ntfy.service_definition,