Deployed version of Documenso.

This commit is contained in:
Patrick Fic
2026-03-26 14:57:09 -07:00
parent 7dab60e3bc
commit 220b1c7968
7 changed files with 7041 additions and 27 deletions

View File

@@ -7,7 +7,7 @@ This Terraform stack deploys Documenso to AWS in `ca-central-1` using:
- 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
- Optional SES domain identity and DKIM management 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
@@ -35,19 +35,21 @@ This is the most practical fit for your Docker Compose workload if you want a ba
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.
4. You will provide SES SMTP credentials. Terraform does not derive SMTP passwords for you.
5. SES identity and DKIM might already be managed outside this stack. By default, this Terraform does not attempt to create them.
6. 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.
7. 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.
8. 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`.
4. Set `manage_ses_resources = true` only if you want this stack to own SES identity verification and DKIM records.
5. Run `terraform init`.
6. Run `terraform plan`.
7. Run `terraform apply`.
## Recommended first production adjustments

View File

@@ -25,6 +25,7 @@ locals {
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}")
app_secret_name = coalesce(var.app_secret_name, "${local.name_prefix}/${replace(var.domain_name, ".", "-")}/app")
common_tags = merge(var.tags, {
Application = var.project_name
ManagedBy = "Terraform"
@@ -192,6 +193,13 @@ resource "aws_route_table_association" "public" {
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "database_public" {
count = var.db_publicly_accessible ? length(aws_subnet.database) : 0
subnet_id = aws_subnet.database[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"
@@ -259,6 +267,17 @@ resource "aws_security_group" "db" {
security_groups = [aws_security_group.ecs.id]
}
dynamic "ingress" {
for_each = var.db_allowed_cidrs
content {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = [ingress.value]
}
}
egress {
from_port = 0
to_port = 0
@@ -306,7 +325,7 @@ resource "aws_db_instance" "postgres" {
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
publicly_accessible = var.db_publicly_accessible
apply_immediately = false
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.db.id]
@@ -314,6 +333,8 @@ resource "aws_db_instance" "postgres" {
performance_insights_enabled = false
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
depends_on = [aws_route_table_association.database_public]
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-postgres"
})
@@ -327,7 +348,7 @@ resource "aws_cloudwatch_log_group" "documenso" {
}
resource "aws_secretsmanager_secret" "app" {
name = "${local.name_prefix}/app"
name = local.app_secret_name
recovery_window_in_days = 7
tags = local.common_tags
@@ -383,7 +404,7 @@ resource "aws_s3_bucket" "uploads" {
bucket = local.s3_bucket_name
lifecycle {
prevent_destroy = true #Remove this to tear down the bucket.
prevent_destroy = false #Remove this to tear down the bucket.
}
tags = merge(local.common_tags, {
@@ -693,29 +714,37 @@ resource "aws_route53_record" "app" {
}
resource "aws_ses_domain_identity" "this" {
count = var.manage_ses_resources ? 1 : 0
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]
count = var.manage_ses_resources ? 1 : 0
zone_id = data.aws_route53_zone.primary.zone_id
name = "_amazonses.${aws_ses_domain_identity.this[0].domain}"
type = "TXT"
ttl = 600
records = [aws_ses_domain_identity.this[0].verification_token]
allow_overwrite = true
}
resource "aws_ses_domain_dkim" "this" {
domain = aws_ses_domain_identity.this.domain
count = var.manage_ses_resources ? 1 : 0
domain = aws_ses_domain_identity.this[0].domain
}
resource "aws_route53_record" "ses_dkim" {
count = 3
count = var.manage_ses_resources ? 3 : 0
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"]
zone_id = data.aws_route53_zone.primary.zone_id
name = "${aws_ses_domain_dkim.this[0].dkim_tokens[count.index]}._domainkey.${aws_ses_domain_identity.this[0].domain}"
type = "CNAME"
ttl = 600
records = ["${aws_ses_domain_dkim.this[0].dkim_tokens[count.index]}.dkim.amazonses.com"]
allow_overwrite = true
}
resource "aws_ecs_task_definition" "documenso" {
@@ -727,6 +756,8 @@ resource "aws_ecs_task_definition" "documenso" {
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
depends_on = [aws_secretsmanager_secret_version.app]
container_definitions = jsonencode([
{
name = "documenso"

View File

@@ -29,8 +29,8 @@ output "secrets_manager_secret_name" {
}
output "ses_identity_domain" {
description = "SES domain verified for outbound mail."
value = aws_ses_domain_identity.this.domain
description = "SES domain used for outbound mail."
value = local.ses_domain
}
output "upload_bucket_name" {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
aws_region = "ca-central-1"
domain_name = "esignature.imex.online"
hosted_zone_name = "imex.online"
documenso_image = "documenso/documenso:latest"
smtp_username = "AKIA2MRSPON3O6PRVUPE"
smtp_password = "pw"
smtp_from_address = "no-reply@imex.online"
manage_ses_resources = false
ses_identity_domain = "imex.online"
app_secret_name = "documenso/esignature-imex-online/app"
# signing_certificate_base64 = "MII...base64-encoded-p12..."
# signing_certificate_passphrase = "replace-with-your-p12-passphrase"
# upload_bucket_name = "esignature-imex-online-documenso"
# Optional tuning
# desired_count = 2
# max_count = 6
db_instance_class = "db.t4g.micro"
db_publicly_accessible = true
db_allowed_cidrs = ["64.46.30.40/32"]
disable_signup = false
# allowed_signup_domains = "imex.online"
# alarm_actions = ["arn:aws:sns:ca-central-1:123456789012:ops-alerts"]

View File

@@ -23,11 +23,17 @@ variable "hosted_zone_name" {
}
variable "ses_identity_domain" {
description = "Domain to verify in SES. Defaults to the hosted zone when null."
description = "Domain used for SES. Defaults to the hosted zone when null. If manage_ses_resources is false, this is informational and used only for outputs/documentation."
type = string
default = null
}
variable "manage_ses_resources" {
description = "Whether this Terraform stack should create and manage the SES domain identity, verification record, and DKIM records. Disable this when SES is already configured elsewhere."
type = bool
default = false
}
variable "documenso_image" {
description = "Container image for Documenso. Default keeps you on the latest published image."
type = string
@@ -166,6 +172,18 @@ variable "db_final_snapshot_on_destroy" {
default = true
}
variable "db_publicly_accessible" {
description = "Whether the RDS instance should have a public endpoint. Requires database subnets with a route to the internet gateway."
type = bool
default = false
}
variable "db_allowed_cidrs" {
description = "IPv4 CIDR blocks allowed to connect directly to PostgreSQL. Leave empty to disable direct public access."
type = list(string)
default = []
}
variable "disable_signup" {
description = "Disable public signup in Documenso."
type = bool
@@ -211,7 +229,7 @@ variable "smtp_password" {
variable "smtp_from_name" {
description = "Display name used in outbound email."
type = string
default = "IMEX eSignature"
default = "ImEX E-Signature"
}
variable "smtp_from_address" {
@@ -233,6 +251,12 @@ variable "signing_certificate_passphrase" {
sensitive = true
}
variable "app_secret_name" {
description = "Secrets Manager secret name used for Documenso application secrets. Set this if a previous secret with the default name is pending deletion."
type = string
default = null
}
variable "tags" {
description = "Additional tags applied to all supported resources."
type = map(string)