OpenTofu/Terraform module: one didbot PDS on AWS

README.md

terraform-aws-didbot-pds #

An OpenTofu/Terraform module that runs one didbot PDS on AWS.

What it deploys #

  • One EC2 instance with an Elastic IP, answering HTTPS on 443 itself. The server gets its own certificates from Let's Encrypt over DNS-01. No load balancer sits in front of it.
  • A separate encrypted EBS volume for the data directory, which outlives the instance. Replacing the instance costs minutes of downtime, not data.
  • Two customer-managed KMS keys: one for both EBS volumes, one for the release parameter.
  • A Route53 hosted zone for the hostname, or an existing one adopted, with an A record at the hostname and a wildcard below it. Each nested zone gets the same two records.
  • An IAM role for the instance: SSM Session Manager, pull from its own ECR repository, read the release parameter, and write only the _acme-challenge TXT and apex CAA records of its own zones.
  • An ECR repository with immutable tags and a lifecycle policy, unless the image comes from a public registry.
  • The release parameter: an SSM SecureString naming the image by digest and the server's config. The instance polls it every thirty seconds and restarts onto a new release in place. A release that fails its health check is rolled back on the instance.
  • Daily AWS Backup snapshots of the data volume.
  • Two Route53 health checks on /health (certificates, disk) with CloudWatch alarms, optionally emailed through an SNS topic.

The data volume and the hosted zone have prevent_destroy.

The zone-layout rule #

Every account the server mints gets a hostname at or below the server's own, so a DID alone says which server hosts it. With the server at pds.example.com:

  • alice.pds.example.com is fine: the wildcard *.pds.example.com answers for it.
  • alice.agents.pds.example.com is fine, with agents.pds.example.com in nested_zones.
  • alice.agents.example.com is not: agents.example.com is a sibling of the server, not below it.

Put the server on a dedicated subdomain, not a registered domain's apex. A wildcard record and certificate at example.com would cover www, api and every other name the domain uses.

Delegation #

With create_hosted_zone = true (the default), the module creates a zone for exactly the hostname. Nothing resolves until the parent zone has an NS record for the hostname naming the four name_servers the apply outputs. The server issues its certificates once that record resolves.

Usage #

module "pds" {
  source = "git::https://tangled.org/permadeath.com/terraform-aws-didbot-pds?ref=v0.1.0"

  providers = {
    aws           = aws
    aws.us_east_1 = aws.us_east_1 # Route53 health metrics live only there
  }

  hostname     = "pds.example.com"
  operator_did = "did:plc:..."
  subnet_id    = "subnet-..."
  ami_id       = "ami-..."
  image_tag    = "v1"
}

examples/minimal is the whole configuration for one hostname in the default VPC, with the bring-up order. examples/did-bot mirrors pds.did.bot.

A first deployment with image_source = "ecr" applies in two steps. First -target=module.pds.aws_ecr_repository.pds creates the repository. Then push the image and apply the rest.

A deploy is an apply with a new image_tag, or with changed config. It rewrites the release parameter and leaves the instance in place. Changing the AMI, the instance type, the unit or the boot script replaces the instance.

Tags come from the provider's default_tags.

Inputs #

Name Default Description
hostname required The server's hostname. Accounts are minted at or below it.
operator_did required The DID of the person who runs the server.
subnet_id required A public subnet. The security group goes in its VPC.
ami_id required An x86_64 AMI with docker, the AWS CLI and curl, such as Amazon Linux 2023. Pin it: a new AMI replaces the instance.
image_tag required The release to run. Never latest.
name_prefix "didbot" Starts every resource name, so copies can share an account. 1 to 34 characters.
nested_zones [] Zones below hostname the server also mints in, each { name, hosted_zone_id }. 0 to 19.
create_hosted_zone true Create a zone for hostname, or adopt hosted_zone_id.
hosted_zone_id null The zone to adopt: hostname's own or a parent's.
hosted_zone_comment "didbot PDS, managed by terraform-aws-didbot-pds" The created zone's comment.
instance_type "t3.small" An x86_64 instance type.
key_name null A key pair for break-glass SSH. No rule opens port 22.
root_volume_size_gb 20 The OS volume. 20 to 100.
data_volume_size_gb 20 The data volume. 10 to 16384. Blobs get half, the log a tenth.
log_budget_bytes null The log's limit before writes are refused. null is a tenth of the data volume.
image_source "ecr" "ecr": this account's own repository. "registry": a public registry, no login.
image_repository null For "registry": host/path with no tag.
image_digest null For "registry": sha256:.... The instance runs nothing else.
ecr_kept_image_count 20 For "ecr": tagged images kept. 5 to 1000.
release_writer_arns [] Principals that may write the release parameter. Empty means whoever applies.
release_health_wait_secs 120 How long a listening release has to pass /health before rollback. 60 to 300.
release_startup_wait_secs 900 How long a release may take to start listening. 600 to 1800.
acme_environment "production" Let's Encrypt "production" or "staging".
acme_contact "" A mailto: for expiry warnings. It is published in the CAA record.
relay_hostnames ["bsky.network"] Relays the operator's announce reaches.
app_view "did:web:api.bsky.app#bsky_appview" Where unproxied app.bsky.* calls go. Empty refuses them.
config_overrides {} More server settings, as { section = { key = value } }.
config_template_path null A config template to use in place of the bundled one.
backup_schedule "cron(0 9 * * ? *)" When the data volume is snapshotted, in UTC.
backup_retention_days 30 Days each snapshot is kept. 7 to 3650.
alarm_email null An address for the alarms. AWS sends it a confirmation link first.
alarm_sns_topic_arns [] Existing us-east-1 topics the alarms also notify.

variables.tf has each input's full description and its checks.

Server settings #

templates/didbot.toml.tftpl is the server's config. It lists every key, each with its default and range. Set one with config_overrides:

config_overrides = {
  capacity = { max_accounts = 5000 }
  writes   = { ops_per_hour = 7200 }
}

A key the template already sets from an input, such as server.port or zone.zones, is refused. Use that input instead. Name pools ([[pool]]) take a template of your own via config_template_path. It is rendered with the same variables as the bundled one.

Outputs #

Name Description
hostname The server's hostname.
name_servers The hosted zone's name servers, for the parent's NS record.
hosted_zone_id The zone holding the records.
instance_public_ip The Elastic IP.
instance_id For aws ssm start-session --target.
data_volume_id The data volume.
ecr_repository_url Where to push, for "ecr"; null otherwise.
image_tag The tag the release names.
image The image the release names, repository@digest.
release_parameter_name The SSM parameter the instance converges on.
backup_vault_name For an on-demand backup before a deploy.
backup_role_arn The role for that backup.
alarm_topic_arn The topic alarm_email subscribes to, or null.

Cost #

Roughly $30 to $35 a month in us-east-1 at on-demand prices with the defaults, before data transfer:

Item Monthly
t3.small instance $15.20
Two 20 GiB gp3 volumes $3.20
Elastic IPv4 address $3.65
Two Route53 health checks, HTTPS with string match $5 to $10
Two KMS keys $2.00
Backups: thirty daily incremental snapshots $1 to $3
Hosted zone, alarms, ECR storage, SSM about $1

A Savings Plan or reserved instance cuts the instance line by about a third. Data transfer out beyond the free 100 GB costs $0.09/GB.

Compatibility #

didbot-pds refuses a config key it does not know. So the bundled templates/didbot.toml.tftpl needs an image at least as new as the keys it sets, and every config_overrides key must be one the image knows.

Module Known to work with didbot images built from
v0.1.0 d27029df (2026-10-14) through ce0e6b04 (2026-10-15)

Secrets and state #

The server makes its signing keys on its data volume, and its Route53 credential is the instance role. The state file holds resource ids, the address and the rendered config. Keep it in an encrypted backend you chose.

Requirements #

  • OpenTofu or Terraform 1.6 or later.
  • The AWS provider, 5.60 or later and below 7.
  • An aws.us_east_1 provider passed in, as above.

Tests #

scripts/test-converge.sh runs the instance's converger against fake aws, docker, systemctl, journalctl and curl.