From 7dab60e3bcbb39954c1b1c011895dde444efb03f Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Thu, 26 Mar 2026 09:15:00 -0700 Subject: [PATCH] Initial terraform for Documenso. --- .gitignore | 2 + documenso/terraform/.terraform.lock.hcl | 45 ++ documenso/terraform/README.md | 57 ++ documenso/terraform/main.tf | 976 ++++++++++++++++++++++++ documenso/terraform/outputs.tf | 44 ++ documenso/terraform/variables.tf | 282 +++++++ 6 files changed, 1406 insertions(+) create mode 100644 documenso/terraform/.terraform.lock.hcl create mode 100644 documenso/terraform/README.md create mode 100644 documenso/terraform/main.tf create mode 100644 documenso/terraform/outputs.tf create mode 100644 documenso/terraform/variables.tf diff --git a/.gitignore b/.gitignore index 4b0d51183..715945f5c 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,5 @@ docker_data /.github/copilot-instructions.md /GEMINI.md /_reference/select-component-test-plan.md + +.terraform \ No newline at end of file diff --git a/documenso/terraform/.terraform.lock.hcl b/documenso/terraform/.terraform.lock.hcl new file mode 100644 index 000000000..606308ac2 --- /dev/null +++ b/documenso/terraform/.terraform.lock.hcl @@ -0,0 +1,45 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.38.0" + constraints = "~> 6.0" + hashes = [ + "h1:RDoKIzXmt7H1mNFcNIyRT+nA/gTJyO3+iW9QGN5I2eQ=", + "zh:143f118ae71059a7a7026c6b950da23fef04a06e2362ffa688bef75e43e869ed", + "zh:29ee220a017306effd877e1280f8b2934dc957e16e0e72ca0222e5514d0db522", + "zh:3a31baabf7aea7aa7669f5a3d76f3445e0e6cce5e9aea0279992765c0df12aee", + "zh:4c1908e62040dbc9901d4426ffb253f53e5dae9e3e1a9125311291ee265c8d8c", + "zh:550f4789f5f5b00e16118d4c17770be3ef4535d6b6928af1cf91ebd30f2c263b", + "zh:6537b7b70bf2c127771b0b84e4b726c834d10666b6104f017edae50c67ebae37", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:af2f9cea0c8bdf5b2a2391f2d179a946c117196f7c829b919673cae3b71d2943", + "zh:c53ffa685381aa4e73158fd9f529239f95938dea330e7aca0b32e7b2a1210432", + "zh:d0995e1d64a7ec8bbc79fc3fbec3749f989e07f211a318705c37cd6a7c7d19e4", + "zh:d2348ffcffc1282983d7a5838dd5d61f372152fe6c0d10868cd6473352318750", + "zh:e449312efb73e4747165e689302a68a1df8ba5755e7f59097069acf82c94f011", + "zh:ec3a538d264ef79380e56fdf107ffb6c0446814f07fc5890c36855fe1e03196b", + "zh:f441e69699b22e32c96a8cdd3bbe694ed302c0dcfe867cd9bd683a16df362714", + "zh:f6f8eaa605ff902234d7e9bdab4fda977185fce14f8576f7b622c914c7d98008", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.8.1" + constraints = "~> 3.6" + hashes = [ + "h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=", + "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", + "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", + "zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57", + "zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0", + "zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66", + "zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9", + "zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05", + "zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8", + "zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b", + "zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699", + ] +} diff --git a/documenso/terraform/README.md b/documenso/terraform/README.md new file mode 100644 index 000000000..909d811bf --- /dev/null +++ b/documenso/terraform/README.md @@ -0,0 +1,57 @@ +# Documenso on AWS + +This Terraform stack deploys Documenso to AWS in `ca-central-1` using: + +- ECS Fargate for the application tier +- RDS PostgreSQL for the database tier +- S3 for document uploads and signed PDFs +- Application Load Balancer with ACM-managed TLS +- Route53 DNS for `esignature.imex.online` +- SES domain identity and DKIM records for outbound email +- Secrets Manager for generated application secrets, SMTP credentials, and the optional Documenso signing certificate +- AWS WAF with a basic managed rule set and rate limiting +- CloudWatch alarms for ALB, ECS, and RDS health indicators + +## Why this shape + +This is the most practical fit for your Docker Compose workload if you want a balance of cost efficiency, managed operations, and scaling: + +- Fargate gives you horizontal scaling without managing EC2 hosts. +- RDS PostgreSQL is simpler and cheaper than Aurora for a single Documenso workload. +- S3-backed uploads are better for production scale and keep document growth out of PostgreSQL. +- The database stays private; the ALB is public. +- The ECS tasks run in public subnets to avoid a NAT gateway charge. Inbound access is still restricted to the ALB security group. +- HTTPS is terminated by the ALB using ACM. The Documenso self-signed `.p12` certificate is separate and is used for document signing, not browser TLS. + +## Files + +- `main.tf`: core infrastructure +- `variables.tf`: configurable inputs +- `outputs.tf`: useful deployment outputs +- `terraform.tfvars.example`: example input values + +## Assumptions built into this stack + +1. Your DNS for `imex.online` is hosted in Route53. +2. You want Multi-AZ RDS enabled from the start for database availability. +3. You are comfortable starting with `documenso/documenso:latest`. For repeatable deployments, pin a version or digest after your first rollout. +4. You will provide SES SMTP credentials. Terraform verifies the SES domain, but it does not derive SMTP passwords for you. +5. You will provide a base64-encoded PKCS#12 signing certificate and passphrase if you want document signing enabled immediately. This stack injects those values through Secrets Manager instead of mounting a host file. +6. You are comfortable with Terraform creating a dedicated IAM user and access key for Documenso S3 uploads because Documenso documents explicit S3 credentials for the upload backend. +7. You want Terraform destroy protection enabled for both the database and the uploads bucket. + +## Deploy + +1. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in the SMTP values. +2. If you want Documenso signing enabled, add `signing_certificate_base64` and `signing_certificate_passphrase`. +3. Optionally set `upload_bucket_name` if you want a specific S3 bucket name. +4. Run `terraform init`. +5. Run `terraform plan`. +6. Run `terraform apply`. + +## Recommended first production adjustments + +1. Pin the Documenso image to a tested version or digest. +2. Wire `alarm_actions` to an SNS topic, PagerDuty bridge, or your on-call system so alarms notify someone. +3. Expand the WAF rule set if you need more aggressive filtering later. +4. Add CloudWatch alarms on ECS 5xx errors, ALB target health, and RDS CPU/storage. \ No newline at end of file diff --git a/documenso/terraform/main.tf b/documenso/terraform/main.tf new file mode 100644 index 000000000..ecb0f351d --- /dev/null +++ b/documenso/terraform/main.tf @@ -0,0 +1,976 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +} + +provider "aws" { + region = var.aws_region +} + +locals { + name_prefix = lower(replace(var.project_name, "_", "-")) + azs = slice(data.aws_availability_zones.available.names, 0, 2) + public_subnet_cidrs = [for index in range(length(local.azs)) : cidrsubnet(var.vpc_cidr, 8, index)] + db_subnet_cidrs = [for index in range(length(local.azs)) : cidrsubnet(var.vpc_cidr, 8, index + 10)] + ses_domain = coalesce(var.ses_identity_domain, var.hosted_zone_name) + smtp_host = "email-smtp.${var.aws_region}.amazonaws.com" + s3_bucket_name = coalesce(var.upload_bucket_name, "${local.name_prefix}-${data.aws_caller_identity.current.account_id}-${var.aws_region}") + common_tags = merge(var.tags, { + Application = var.project_name + ManagedBy = "Terraform" + }) + app_secret_values = merge( + { + NEXTAUTH_SECRET = random_password.nextauth_secret.result + NEXT_PRIVATE_ENCRYPTION_KEY = random_password.encryption_key_primary.result + NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY = random_password.encryption_key_secondary.result + NEXT_PRIVATE_DATABASE_URL = "postgresql://${var.db_username}:${random_password.db_password.result}@${aws_db_instance.postgres.address}:5432/${var.db_name}?schema=public" + NEXT_PRIVATE_DIRECT_DATABASE_URL = "postgresql://${var.db_username}:${random_password.db_password.result}@${aws_db_instance.postgres.address}:5432/${var.db_name}?schema=public" + NEXT_PRIVATE_SMTP_USERNAME = var.smtp_username + NEXT_PRIVATE_SMTP_PASSWORD = var.smtp_password + NEXT_PRIVATE_SMTP_FROM_NAME = var.smtp_from_name + NEXT_PRIVATE_SMTP_FROM_ADDRESS = var.smtp_from_address + NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS = var.allowed_signup_domains + NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID = aws_iam_access_key.documenso_upload.id + NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY = aws_iam_access_key.documenso_upload.secret + }, + trimspace(var.signing_certificate_base64) != "" ? { + NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS = var.signing_certificate_base64 + } : {}, + trimspace(var.signing_certificate_passphrase) != "" ? { + NEXT_PRIVATE_SIGNING_PASSPHRASE = var.signing_certificate_passphrase + } : {} + ) + app_secret_env = concat( + [ + for secret_name in [ + "NEXTAUTH_SECRET", + "NEXT_PRIVATE_ENCRYPTION_KEY", + "NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY", + "NEXT_PRIVATE_DATABASE_URL", + "NEXT_PRIVATE_DIRECT_DATABASE_URL", + "NEXT_PRIVATE_SMTP_USERNAME", + "NEXT_PRIVATE_SMTP_PASSWORD", + "NEXT_PRIVATE_SMTP_FROM_NAME", + "NEXT_PRIVATE_SMTP_FROM_ADDRESS", + "NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS", + "NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID", + "NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY" + ] : { + name = secret_name + valueFrom = "${aws_secretsmanager_secret.app.arn}:${secret_name}::" + } + ], + trimspace(var.signing_certificate_base64) != "" ? [ + { + name = "NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS" + valueFrom = "${aws_secretsmanager_secret.app.arn}:NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS::" + } + ] : [], + trimspace(var.signing_certificate_passphrase) != "" ? [ + { + name = "NEXT_PRIVATE_SIGNING_PASSPHRASE" + valueFrom = "${aws_secretsmanager_secret.app.arn}:NEXT_PRIVATE_SIGNING_PASSPHRASE::" + } + ] : [] + ) +} + +data "aws_caller_identity" "current" {} + +data "aws_availability_zones" "available" { + state = "available" +} + +data "aws_route53_zone" "primary" { + name = var.hosted_zone_name + private_zone = false +} + +data "aws_rds_engine_version" "postgres" { + engine = "postgres" + version = var.postgres_major_version + latest = true +} + +resource "random_password" "db_password" { + length = 32 + special = false +} + +resource "random_password" "nextauth_secret" { + length = 64 + special = false +} + +resource "random_password" "encryption_key_primary" { + length = 64 + special = false +} + +resource "random_password" "encryption_key_secondary" { + length = 64 + special = false +} + +resource "random_id" "final_snapshot" { + byte_length = 4 +} + +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-vpc" + }) +} + +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-igw" + }) +} + +resource "aws_subnet" "public" { + count = length(local.azs) + + vpc_id = aws_vpc.this.id + cidr_block = local.public_subnet_cidrs[count.index] + availability_zone = local.azs[count.index] + map_public_ip_on_launch = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-public-${count.index + 1}" + Tier = "public" + }) +} + +resource "aws_subnet" "database" { + count = length(local.azs) + + vpc_id = aws_vpc.this.id + cidr_block = local.db_subnet_cidrs[count.index] + availability_zone = local.azs[count.index] + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-database-${count.index + 1}" + Tier = "database" + }) +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-public-rt" + }) +} + +resource "aws_route_table_association" "public" { + count = length(aws_subnet.public) + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +resource "aws_security_group" "alb" { + name = "${local.name_prefix}-alb-sg" + description = "Public ingress to the Documenso load balancer" + vpc_id = aws_vpc.this.id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb-sg" + }) +} + +resource "aws_security_group" "ecs" { + name = "${local.name_prefix}-ecs-sg" + description = "Restrict Documenso container access to the ALB" + vpc_id = aws_vpc.this.id + + ingress { + from_port = var.app_port + to_port = var.app_port + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecs-sg" + }) +} + +resource "aws_security_group" "db" { + name = "${local.name_prefix}-db-sg" + description = "Allow PostgreSQL access only from Documenso tasks" + vpc_id = aws_vpc.this.id + + ingress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.ecs.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-db-sg" + }) +} + +resource "aws_db_subnet_group" "this" { + name = "${local.name_prefix}-db-subnets" + subnet_ids = aws_subnet.database[*].id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-db-subnets" + }) +} + +resource "aws_db_parameter_group" "postgres" { + name = "${local.name_prefix}-postgres${var.postgres_major_version}" + family = "postgres${var.postgres_major_version}" + + tags = local.common_tags +} + +resource "aws_db_instance" "postgres" { + identifier = "${local.name_prefix}-postgres" + engine = "postgres" + engine_version = data.aws_rds_engine_version.postgres.version_actual + instance_class = var.db_instance_class + allocated_storage = var.db_allocated_storage + max_allocated_storage = var.db_max_allocated_storage + storage_type = "gp3" + storage_encrypted = true + db_name = var.db_name + username = var.db_username + password = random_password.db_password.result + port = 5432 + backup_retention_period = var.db_backup_retention_days + multi_az = var.db_multi_az + deletion_protection = var.db_deletion_protection + skip_final_snapshot = !var.db_final_snapshot_on_destroy + final_snapshot_identifier = var.db_final_snapshot_on_destroy ? "${local.name_prefix}-final-${random_id.final_snapshot.hex}" : null + auto_minor_version_upgrade = true + publicly_accessible = false + apply_immediately = false + db_subnet_group_name = aws_db_subnet_group.this.name + vpc_security_group_ids = [aws_security_group.db.id] + parameter_group_name = aws_db_parameter_group.postgres.name + performance_insights_enabled = false + enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"] + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-postgres" + }) +} + +resource "aws_cloudwatch_log_group" "documenso" { + name = "/ecs/${local.name_prefix}" + retention_in_days = 30 + + tags = local.common_tags +} + +resource "aws_secretsmanager_secret" "app" { + name = "${local.name_prefix}/app" + recovery_window_in_days = 7 + + tags = local.common_tags +} + +resource "aws_secretsmanager_secret_version" "app" { + secret_id = aws_secretsmanager_secret.app.id + + secret_string = jsonencode(local.app_secret_values) +} + +resource "aws_iam_role" "ecs_task_execution" { + name = "${local.name_prefix}-ecs-execution" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + }] + }) + + tags = local.common_tags +} + +resource "aws_iam_role_policy_attachment" "ecs_task_execution" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy" "ecs_task_execution_secrets" { + name = "${local.name_prefix}-ecs-secrets" + role = aws_iam_role.ecs_task_execution.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue" + ] + Resource = aws_secretsmanager_secret.app.arn + } + ] + }) +} + +resource "aws_s3_bucket" "uploads" { + bucket = local.s3_bucket_name + + lifecycle { + prevent_destroy = true #Remove this to tear down the bucket. + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-uploads" + }) +} + +resource "aws_s3_bucket_versioning" "uploads" { + bucket = aws_s3_bucket.uploads.id + + versioning_configuration { + status = var.s3_versioning_enabled ? "Enabled" : "Suspended" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "uploads" { + bucket = aws_s3_bucket.uploads.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_public_access_block" "uploads" { + bucket = aws_s3_bucket.uploads.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "uploads" { + bucket = aws_s3_bucket.uploads.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_cors_configuration" "uploads" { + bucket = aws_s3_bucket.uploads.id + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["GET", "PUT", "POST"] + allowed_origins = ["https://${var.domain_name}"] + expose_headers = ["ETag"] + max_age_seconds = 3000 + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "uploads" { + bucket = aws_s3_bucket.uploads.id + + rule { + id = "abort-incomplete-multipart-uploads" + status = "Enabled" + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } +} + +resource "aws_iam_user" "documenso_upload" { + name = "${local.name_prefix}-upload" + + tags = local.common_tags +} + +resource "aws_iam_access_key" "documenso_upload" { + user = aws_iam_user.documenso_upload.name +} + +resource "aws_iam_user_policy" "documenso_upload" { + name = "${local.name_prefix}-upload-s3" + user = aws_iam_user.documenso_upload.name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:ListBucket" + ] + Resource = aws_s3_bucket.uploads.arn + }, + { + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + Resource = "${aws_s3_bucket.uploads.arn}/*" + } + ] + }) +} + +resource "aws_iam_role" "ecs_task" { + name = "${local.name_prefix}-ecs-task" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + }] + }) + + tags = local.common_tags +} + +resource "aws_ecs_cluster" "this" { + name = "${local.name_prefix}-cluster" + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = local.common_tags +} + +resource "aws_acm_certificate" "this" { + domain_name = var.domain_name + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = local.common_tags +} + +resource "aws_route53_record" "certificate_validation" { + for_each = { + for dvo in aws_acm_certificate.this.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + zone_id = data.aws_route53_zone.primary.zone_id + name = each.value.name + type = each.value.type + ttl = 60 + records = [each.value.record] +} + +resource "aws_acm_certificate_validation" "this" { + certificate_arn = aws_acm_certificate.this.arn + validation_record_fqdns = [for record in aws_route53_record.certificate_validation : record.fqdn] +} + +resource "aws_lb" "this" { + name = substr("${local.name_prefix}-alb", 0, 32) + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.public[*].id + idle_timeout = 60 + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb" + }) +} + +resource "aws_lb_target_group" "documenso" { + name = substr("${local.name_prefix}-tg", 0, 32) + port = var.app_port + protocol = "HTTP" + target_type = "ip" + vpc_id = aws_vpc.this.id + + health_check { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 3 + interval = 30 + timeout = 5 + path = "/" + matcher = "200-399" + } + + tags = local.common_tags +} + +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.this.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } +} + +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.this.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = aws_acm_certificate.this.arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.documenso.arn + } + + depends_on = [aws_acm_certificate_validation.this] +} + +resource "aws_wafv2_web_acl" "this" { + name = "${local.name_prefix}-web-acl" + description = "WAF protection for Documenso" + scope = "REGIONAL" + + default_action { + allow {} + } + + rule { + name = "AWSManagedRulesCommonRuleSet" + priority = 1 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "CommonRuleSet" + sampled_requests_enabled = true + } + } + + rule { + name = "RateLimitPerIp" + priority = 2 + + action { + block {} + } + + statement { + rate_based_statement { + limit = var.waf_rate_limit + aggregate_key_type = "IP" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "RateLimitPerIp" + sampled_requests_enabled = true + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${replace(local.name_prefix, "-", "")}-web-acl" + sampled_requests_enabled = true + } + + tags = local.common_tags +} + +resource "aws_wafv2_web_acl_association" "alb" { + resource_arn = aws_lb.this.arn + web_acl_arn = aws_wafv2_web_acl.this.arn +} + +resource "aws_route53_record" "app" { + zone_id = data.aws_route53_zone.primary.zone_id + name = var.domain_name + type = "A" + + alias { + name = aws_lb.this.dns_name + zone_id = aws_lb.this.zone_id + evaluate_target_health = true + } +} + +resource "aws_ses_domain_identity" "this" { + domain = local.ses_domain +} + +resource "aws_route53_record" "ses_verification" { + zone_id = data.aws_route53_zone.primary.zone_id + name = "_amazonses.${aws_ses_domain_identity.this.domain}" + type = "TXT" + ttl = 600 + records = [aws_ses_domain_identity.this.verification_token] +} + +resource "aws_ses_domain_dkim" "this" { + domain = aws_ses_domain_identity.this.domain +} + +resource "aws_route53_record" "ses_dkim" { + count = 3 + + zone_id = data.aws_route53_zone.primary.zone_id + name = "${aws_ses_domain_dkim.this.dkim_tokens[count.index]}._domainkey.${aws_ses_domain_identity.this.domain}" + type = "CNAME" + ttl = 600 + records = ["${aws_ses_domain_dkim.this.dkim_tokens[count.index]}.dkim.amazonses.com"] +} + +resource "aws_ecs_task_definition" "documenso" { + family = "${local.name_prefix}-task" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.fargate_cpu) + memory = tostring(var.fargate_memory) + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([ + { + name = "documenso" + image = var.documenso_image + essential = true + portMappings = [ + { + containerPort = var.app_port + hostPort = var.app_port + protocol = "tcp" + } + ] + environment = [ + { name = "PORT", value = tostring(var.app_port) }, + { name = "NEXT_PUBLIC_WEBAPP_URL", value = "https://${var.domain_name}" }, + { name = "NEXT_PRIVATE_INTERNAL_WEBAPP_URL", value = "http://127.0.0.1:${var.app_port}" }, + { name = "NEXT_PUBLIC_UPLOAD_TRANSPORT", value = "s3" }, + { name = "NEXT_PRIVATE_UPLOAD_BUCKET", value = aws_s3_bucket.uploads.bucket }, + { name = "NEXT_PRIVATE_UPLOAD_REGION", value = var.aws_region }, + { name = "NEXT_PRIVATE_SMTP_TRANSPORT", value = "smtp-auth" }, + { name = "NEXT_PRIVATE_SMTP_HOST", value = local.smtp_host }, + { name = "NEXT_PRIVATE_SMTP_PORT", value = tostring(var.smtp_port) }, + { name = "NEXT_PRIVATE_SMTP_SECURE", value = var.smtp_secure ? "true" : "false" }, + { name = "NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS", value = var.smtp_unsafe_ignore_tls ? "true" : "false" }, + { name = "NEXT_PUBLIC_DISABLE_SIGNUP", value = var.disable_signup ? "true" : "false" }, + { name = "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT", value = tostring(var.document_size_upload_limit_mb) } + ] + secrets = local.app_secret_env + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.documenso.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "documenso" + } + } + } + ]) + + tags = local.common_tags +} + +resource "aws_ecs_service" "documenso" { + name = "${local.name_prefix}-service" + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.documenso.arn + desired_count = var.desired_count + launch_type = "FARGATE" + health_check_grace_period_seconds = 60 + deployment_maximum_percent = 200 + deployment_minimum_healthy_percent = 100 + enable_execute_command = true + + deployment_circuit_breaker { + enable = true + rollback = true + } + + network_configuration { + subnets = aws_subnet.public[*].id + security_groups = [aws_security_group.ecs.id] + assign_public_ip = true + } + + load_balancer { + target_group_arn = aws_lb_target_group.documenso.arn + container_name = "documenso" + container_port = var.app_port + } + + depends_on = [aws_lb_listener.https] + + tags = local.common_tags +} + +resource "aws_appautoscaling_target" "ecs" { + max_capacity = var.max_count + min_capacity = var.min_count + resource_id = "service/${aws_ecs_cluster.this.name}/${aws_ecs_service.documenso.name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +resource "aws_appautoscaling_policy" "cpu" { + name = "${local.name_prefix}-cpu-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.ecs.resource_id + scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension + service_namespace = aws_appautoscaling_target.ecs.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + + target_value = var.cpu_target_utilization + scale_in_cooldown = 120 + scale_out_cooldown = 60 + } +} + +resource "aws_appautoscaling_policy" "memory" { + name = "${local.name_prefix}-memory-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.ecs.resource_id + scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension + service_namespace = aws_appautoscaling_target.ecs.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageMemoryUtilization" + } + + target_value = var.memory_target_utilization + scale_in_cooldown = 120 + scale_out_cooldown = 60 + } +} + +resource "aws_cloudwatch_metric_alarm" "alb_5xx" { + alarm_name = "${local.name_prefix}-alb-5xx" + alarm_description = "ALB is returning elevated 5xx responses" + namespace = "AWS/ApplicationELB" + metric_name = "HTTPCode_ELB_5XX_Count" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = var.alb_5xx_alarm_threshold + comparison_operator = "GreaterThanOrEqualToThreshold" + treat_missing_data = "notBreaching" + alarm_actions = var.alarm_actions + ok_actions = var.alarm_actions + + dimensions = { + LoadBalancer = aws_lb.this.arn_suffix + } + + tags = local.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_hosts" { + alarm_name = "${local.name_prefix}-alb-unhealthy-hosts" + alarm_description = "ALB target group has unhealthy hosts" + namespace = "AWS/ApplicationELB" + metric_name = "UnHealthyHostCount" + statistic = "Average" + period = 60 + evaluation_periods = 2 + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + treat_missing_data = "notBreaching" + alarm_actions = var.alarm_actions + ok_actions = var.alarm_actions + + dimensions = { + LoadBalancer = aws_lb.this.arn_suffix + TargetGroup = aws_lb_target_group.documenso.arn_suffix + } + + tags = local.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "ecs_cpu_high" { + alarm_name = "${local.name_prefix}-ecs-cpu-high" + alarm_description = "Documenso ECS service CPU is consistently high" + namespace = "AWS/ECS" + metric_name = "CPUUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 2 + threshold = var.ecs_cpu_alarm_threshold + comparison_operator = "GreaterThanOrEqualToThreshold" + treat_missing_data = "notBreaching" + alarm_actions = var.alarm_actions + ok_actions = var.alarm_actions + + dimensions = { + ClusterName = aws_ecs_cluster.this.name + ServiceName = aws_ecs_service.documenso.name + } + + tags = local.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "ecs_memory_high" { + alarm_name = "${local.name_prefix}-ecs-memory-high" + alarm_description = "Documenso ECS service memory is consistently high" + namespace = "AWS/ECS" + metric_name = "MemoryUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 2 + threshold = var.ecs_memory_alarm_threshold + comparison_operator = "GreaterThanOrEqualToThreshold" + treat_missing_data = "notBreaching" + alarm_actions = var.alarm_actions + ok_actions = var.alarm_actions + + dimensions = { + ClusterName = aws_ecs_cluster.this.name + ServiceName = aws_ecs_service.documenso.name + } + + tags = local.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" { + alarm_name = "${local.name_prefix}-rds-cpu-high" + alarm_description = "RDS CPU utilization is high" + namespace = "AWS/RDS" + metric_name = "CPUUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 2 + threshold = var.rds_cpu_alarm_threshold + comparison_operator = "GreaterThanOrEqualToThreshold" + treat_missing_data = "notBreaching" + alarm_actions = var.alarm_actions + ok_actions = var.alarm_actions + + dimensions = { + DBInstanceIdentifier = aws_db_instance.postgres.id + } + + tags = local.common_tags +} + +resource "aws_cloudwatch_metric_alarm" "rds_free_storage_low" { + alarm_name = "${local.name_prefix}-rds-free-storage-low" + alarm_description = "RDS free storage is running low" + namespace = "AWS/RDS" + metric_name = "FreeStorageSpace" + statistic = "Average" + period = 300 + evaluation_periods = 1 + threshold = var.rds_free_storage_alarm_threshold_bytes + comparison_operator = "LessThanOrEqualToThreshold" + treat_missing_data = "notBreaching" + alarm_actions = var.alarm_actions + ok_actions = var.alarm_actions + + dimensions = { + DBInstanceIdentifier = aws_db_instance.postgres.id + } + + tags = local.common_tags +} \ No newline at end of file diff --git a/documenso/terraform/outputs.tf b/documenso/terraform/outputs.tf new file mode 100644 index 000000000..9dde42618 --- /dev/null +++ b/documenso/terraform/outputs.tf @@ -0,0 +1,44 @@ +output "application_url" { + description = "Public URL for the Documenso deployment." + value = "https://${var.domain_name}" +} + +output "load_balancer_dns_name" { + description = "DNS name assigned to the application load balancer." + value = aws_lb.this.dns_name +} + +output "database_endpoint" { + description = "RDS PostgreSQL endpoint for the application." + value = aws_db_instance.postgres.address +} + +output "postgres_engine_version" { + description = "Resolved PostgreSQL engine version deployed to RDS." + value = aws_db_instance.postgres.engine_version +} + +output "ecs_cluster_name" { + description = "ECS cluster name running the Documenso service." + value = aws_ecs_cluster.this.name +} + +output "secrets_manager_secret_name" { + description = "Secrets Manager secret that stores generated and supplied application secrets." + value = aws_secretsmanager_secret.app.name +} + +output "ses_identity_domain" { + description = "SES domain verified for outbound mail." + value = aws_ses_domain_identity.this.domain +} + +output "upload_bucket_name" { + description = "S3 bucket used for Documenso uploads." + value = aws_s3_bucket.uploads.bucket +} + +output "waf_web_acl_arn" { + description = "ARN of the WAF web ACL attached to the ALB." + value = aws_wafv2_web_acl.this.arn +} \ No newline at end of file diff --git a/documenso/terraform/variables.tf b/documenso/terraform/variables.tf new file mode 100644 index 000000000..bdb675833 --- /dev/null +++ b/documenso/terraform/variables.tf @@ -0,0 +1,282 @@ +variable "aws_region" { + description = "AWS region for the deployment." + type = string + default = "ca-central-1" +} + +variable "project_name" { + description = "Logical name used to prefix created resources." + type = string + default = "documenso" +} + +variable "domain_name" { + description = "Fully qualified domain name for the application." + type = string + default = "esignature.imex.online" +} + +variable "hosted_zone_name" { + description = "Public Route53 hosted zone that contains the application hostname." + type = string + default = "imex.online" +} + +variable "ses_identity_domain" { + description = "Domain to verify in SES. Defaults to the hosted zone when null." + type = string + default = null +} + +variable "documenso_image" { + description = "Container image for Documenso. Default keeps you on the latest published image." + type = string + default = "documenso/documenso:latest" +} + +variable "app_port" { + description = "Container port exposed by Documenso." + type = number + default = 3000 +} + +variable "upload_bucket_name" { + description = "Optional S3 bucket name for Documenso uploads. If null, Terraform generates a globally unique name based on account and region." + type = string + default = null +} + +variable "s3_versioning_enabled" { + description = "Enable S3 object versioning for uploaded documents." + type = bool + default = true +} + +variable "document_size_upload_limit_mb" { + description = "Upload size limit shown in the Documenso UI, in MB." + type = number + default = 10 +} + +variable "vpc_cidr" { + description = "CIDR block used for the VPC." + type = string + default = "10.42.0.0/16" +} + +variable "fargate_cpu" { + description = "Fargate CPU units for the task." + type = number + default = 512 +} + +variable "fargate_memory" { + description = "Fargate memory in MiB for the task." + type = number + default = 1024 +} + +variable "desired_count" { + description = "Initial number of running Documenso tasks." + type = number + default = 1 +} + +variable "min_count" { + description = "Minimum number of tasks for autoscaling." + type = number + default = 1 +} + +variable "max_count" { + description = "Maximum number of tasks for autoscaling." + type = number + default = 4 +} + +variable "cpu_target_utilization" { + description = "Target average CPU utilization for ECS autoscaling." + type = number + default = 65 +} + +variable "memory_target_utilization" { + description = "Target average memory utilization for ECS autoscaling." + type = number + default = 75 +} + +variable "postgres_major_version" { + description = "Preferred PostgreSQL major version. Terraform resolves the latest matching minor release supported by AWS." + type = string + default = "17" +} + +variable "db_name" { + description = "Initial PostgreSQL database name." + type = string + default = "documenso" +} + +variable "db_username" { + description = "Master PostgreSQL username for the application." + type = string + default = "documenso" +} + +variable "db_instance_class" { + description = "RDS instance class. Graviton classes are usually the best cost/performance option for Postgres." + type = string + default = "db.t4g.small" +} + +variable "db_allocated_storage" { + description = "Initial allocated storage in GiB." + type = number + default = 20 +} + +variable "db_max_allocated_storage" { + description = "Maximum autoscaled storage in GiB." + type = number + default = 100 +} + +variable "db_backup_retention_days" { + description = "How many days of automated backups to retain." + type = number + default = 7 +} + +variable "db_multi_az" { + description = "Enable Multi-AZ for higher database availability at higher cost." + type = bool + default = true +} + +variable "db_deletion_protection" { + description = "Protect the database from accidental deletion." + type = bool + default = true +} + +variable "db_final_snapshot_on_destroy" { + description = "Create a final snapshot if the database is destroyed." + type = bool + default = true +} + +variable "disable_signup" { + description = "Disable public signup in Documenso." + type = bool + default = true +} + +variable "allowed_signup_domains" { + description = "Optional comma-separated list of allowed email domains when signup is enabled." + type = string + default = "" +} + +variable "smtp_port" { + description = "SES SMTP endpoint port." + type = number + default = 587 +} + +variable "smtp_secure" { + description = "Whether to use SMTPS. Keep false for SES on port 587 with STARTTLS." + type = bool + default = false +} + +variable "smtp_unsafe_ignore_tls" { + description = "Whether the application should ignore TLS issues when sending mail." + type = bool + default = false +} + +variable "smtp_username" { + description = "SES SMTP username." + type = string + sensitive = true +} + +variable "smtp_password" { + description = "SES SMTP password." + type = string + sensitive = true +} + +variable "smtp_from_name" { + description = "Display name used in outbound email." + type = string + default = "IMEX eSignature" +} + +variable "smtp_from_address" { + description = "Verified sender email address for SES." + type = string +} + +variable "signing_certificate_base64" { + description = "Base64-encoded PKCS#12 signing certificate contents for Documenso. Leave empty to omit certificate injection." + type = string + default = "" + sensitive = true +} + +variable "signing_certificate_passphrase" { + description = "Passphrase for the Documenso signing certificate. Leave empty to omit it." + type = string + default = "" + sensitive = true +} + +variable "tags" { + description = "Additional tags applied to all supported resources." + type = map(string) + default = {} +} + +variable "waf_rate_limit" { + description = "Maximum requests per 5-minute window from a single IP before WAF blocks it." + type = number + default = 2000 +} + +variable "alarm_actions" { + description = "Optional list of SNS topic ARNs or other alarm actions to invoke when CloudWatch alarms fire." + type = list(string) + default = [] +} + +variable "alb_5xx_alarm_threshold" { + description = "Threshold for ALB 5xx count over a 5-minute period." + type = number + default = 10 +} + +variable "ecs_cpu_alarm_threshold" { + description = "Threshold for average ECS CPU utilization alarm." + type = number + default = 85 +} + +variable "ecs_memory_alarm_threshold" { + description = "Threshold for average ECS memory utilization alarm." + type = number + default = 85 +} + +variable "rds_cpu_alarm_threshold" { + description = "Threshold for average RDS CPU utilization alarm." + type = number + default = 80 +} + +variable "rds_free_storage_alarm_threshold_bytes" { + description = "Alarm threshold for low RDS free storage, in bytes." + type = number + default = 5368709120 +} \ No newline at end of file