Initial terraform for Documenso.

This commit is contained in:
Patrick Fic
2026-03-26 09:15:00 -07:00
parent d4c7298334
commit 7dab60e3bc
6 changed files with 1406 additions and 0 deletions

2
.gitignore vendored
View File

@@ -152,3 +152,5 @@ docker_data
/.github/copilot-instructions.md
/GEMINI.md
/_reference/select-component-test-plan.md
.terraform

45
documenso/terraform/.terraform.lock.hcl generated Normal file
View File

@@ -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",
]
}

View File

@@ -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.

976
documenso/terraform/main.tf Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}