- HCL 69%
- Shell 31%
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-challengeTXT 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.comis fine: the wildcard*.pds.example.comanswers for it.alice.agents.pds.example.comis fine, withagents.pds.example.cominnested_zones.alice.agents.example.comis not:agents.example.comis 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_1provider passed in, as above.
Tests #
scripts/test-converge.sh runs the instance's converger against fake
aws, docker, systemctl, journalctl and curl.