# Technical documentation — terraform_scripts

Evidence date: 2026-10-01. Revision: `main` at `8af8aa6`.

## What this repository is

Terraform for the Datafy production AWS account. It is not an application repository. Languages in tree: HCL, one bash user-data script, one inline Python placeholder string, and three AWS Config conformance-pack YAML files. No `package.json`, `go.mod`, `requirements.txt`, Dockerfile, or test runner.

## Layout

```text
environments/production/     root module (Terraform Cloud workspace Production)
environments/common.tfvars   not auto-loaded; differs from production's auto tfvars
stacks/networking/           VPC, NACLs, security groups, flow logs, S3 endpoint
stacks/baseline/             Security Hub, GuardDuty, Config, CloudTrail, account settings
stacks/datafy/               calls baseline, then EC2, RDS, proxy, S3, Lambda, ALB, WAF, CDN
modules/aws-so-*             reusable modules (some unused — see features.md)
modules/aws-*                guardduty, cloudtrail, kms, account settings, log group
README.md
datafy project redeployment.jpg
```

Root module wiring is only `environments/production/main.tf`. `stacks/datafy/outputs.tf` is an empty file.

## Tooling and versions

| Item | Value | Source |
|---|---|---|
| State | Terraform Cloud, org `Datafy-AWS-Deploy`, workspace `Production` | `environments/production/backend.tf` |
| AWS provider (root) | `~> 6.0` | `providers.tf` |
| AWS provider (stacks) | `>= 5.0`, datafy stack also requires alias `aws.us-east-1` | `stacks/*/versions.tf` |
| VPC module | `terraform-aws-modules/vpc/aws` `~> 5.2.0` | `stacks/networking/networking.tf` |
| Security group module | `~> 5.1.2` | `security_groups.tf` |
| ALB module | `~> 9.10.0` | `load_balancers.tf` |
| RDS module | `6.9.0` | `modules/aws-so-rds/rds.tf` |
| Extra providers | `cloudposse/awsutils`, `awscc` (account settings) | `modules/aws-account-settings` |
| Terraform `required_version` | not set | — |
| CI | none in git | no pipeline file |
| Tests | none | — |

Apply credentials and the Terraform Cloud team token are not in the repository. **Unknown / Requires Confirmation:** which Terraform CLI version the workspace uses, and whether applies are local or VCS-driven. No VCS connection is declared in the HCL.

## Network

Production inputs (`production.auto.tfvars`):

- Region `eu-west-1`, AZs `eu-west-1a` and `eu-west-1b`.
- VPC `10.1.0.0/16`.
- Public: `10.1.1.0/24`, `10.1.2.0/24`, `10.1.3.0/24`.
- Private: `10.1.11.0/24` through `10.1.19.0/24` (nine prefixes).
- Database: `10.1.21.0/24`, `10.1.22.0/24`.
- `single_nat_gateway = true`.
- `create_database_subnet_group = true` from `common.auto.tfvars` (the unloaded `environments/common.tfvars` says false).

The VPC module is given two public names and eight private names. That is shorter than the CIDR lists. See PRD FR-016.

S3 endpoint: gateway type, associated with public, private, and database route tables (`modules/aws-so-vpc-endpoints`). No interface endpoints (no endpoints for Secrets Manager, CloudWatch, or STS) are declared. Private instances reach those APIs through the NAT gateway unless something outside this module adds them.

Security-group egress is `all-all` on every group. Lambda has no ingress.

NACL coverage by subnet index (private list): 0–1 supabase, 2–3 waterwheel, 4–5 qwen, 6 through the end lambda. A ninth private subnet would fall into the lambda NACL.

## Compute

AMI filter: owner `099720109477`, name `ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*`, HVM, most recent.

| Instance local name | Type | Port | Root volume | Subnet |
|---|---|---|---|---|
| `datafy-prod-supabase-server` | t3.xlarge | 8000 | 100 GB gp3 encrypted | `private_subnets[0]` |
| `water-wheel-supabase-server` | c7i.2xlarge | 80 | 60 GB gp3 encrypted | `private_subnets[1]` |
| `qwen3-supabase-server` | g4dn.xlarge | 80 | 50 GB gp3 encrypted | `private_subnets[1]` |

`g4dn.xlarge` is a GPU instance type. No GPU driver, CUDA, or model artifact is in this repo.

Launch template: `key_name = "${service_name}-${name}-kp"`, EBS `delete_on_termination = false`, detailed monitoring, `http_tokens = required`. There is no `prevent_destroy` lifecycle in the current tree. The commit message "update volume prevent destroy" (`b34cf9d`) does not correspond to a `prevent_destroy` block at `8af8aa6`.

`aws_instance` uses launch-template version `$Latest` and then ignores `launch_template` changes.

User data (`modules/aws-so-ec2/user_data.txt`):

```text
systemctl enable codedeploy-agent
snap switch --channel=candidate amazon-ssm-agent
systemctl start snap.amazon-ssm-agent.amazon-ssm-agent.service
```

The script does not install `codedeploy-agent` if the AMI lacks it. It records status and does not exit non-zero on failure.

IAM (`modules/aws-so-ec2/data.tf`):

- Trust: `ec2.amazonaws.com`.
- AWS managed `AmazonSSMManagedInstanceCore`.
- Logs create/put on `arn:aws:logs:*:*:*`.
- S3 read/write on the server-artifacts bucket and on the Supabase storage bucket (passed from `stacks/datafy/ec2.tf`).
- `secretsmanager:GetSecretValue` on `arn:aws:secretsmanager:*:*:secret:rds!db-*`.
- `ec2:ModifyInstanceMetadataOptions` on `arn:aws:ec2:eu-west-1:926753675240:instance/i-0e3ee87bcaac5d0fd` only.
- `ec2:DescribeInstances` on `*`.

Account id `926753675240` appears only in that one ARN. It is the only AWS account id hardcoded in the tree.

## Load balancing

Module `terraform-aws-modules/alb/aws` name `datafy-prod-alb-public`.

- Subnets: first two public subnet ids.
- `internal = false`, `create_security_group = false`, uses `alb_public_sg`.
- `enable_deletion_protection = true`, `preserve_host_header = true`, `idle_timeout = 180`.
- Listener 80: redirect to 443, `HTTP_301`.
- Listener 443: policy `ELBSecurityPolicy-TLS13-1-2-2021-06`, cert from `module.acm-alb`, additional cert from the waterwheel data source.
- Default action: fixed response 200, `text/plain`, body `default response`.
- Rules at priorities 10, 20, 30 as in the PRD.
- Access logs and connection logs enabled, bucket from networking, prefix `alb`.

Target group name `${name}-${port}` (for example `water-wheel-supabase-server-80`). Protocol HTTP. Health check interval 30, timeout 5, thresholds 3/3.

## Data layer

RDS (`modules/aws-so-rds/rds.tf`):

- `publicly_accessible = false`, `storage_encrypted = true`, `storage_type = gp3`.
- `manage_master_user_password = true`.
- `auto_minor_version_upgrade = true`. `engine_version` and `major_engine_version` are both the string `15`.
- `skip_final_snapshot = false`, `deletion_protection = true`, `copy_tags_to_snapshot = true`.
- CloudWatch log group retention 365 days. The `enabled_cloudwatch_logs_exports` line is commented out, so PostgreSQL log types are not selected in code.
- Performance Insights retention 7 days. Enhanced monitoring interval 60. Monitoring role name `monitoring-postgres-role` with a name prefix.
- Parameter list is empty.
- Tags passed into the RDS module drop key `env`.
- Event subscription categories: deletion, failover, failure, low storage, maintenance, recovery, restoration, security. Topic is the baseline technical-alerts topic.

RDS Proxy:

- `require_tls = true`, `iam_auth = DISABLED`, `idle_client_timeout = 1800`, `debug_logging = false`.
- Auth scheme `SECRETS`, secret ARN from the RDS master user secret.
- `max_connections_percent = 90`.
- Security group allows 5432 from EC2 and Lambda. Database security group allows 5432 from the proxy and from EC2.

## Lambda

`data.archive_file.placeholder` writes `handler.py`:

```python
def handler(event, context):
    return {'statusCode': 200, 'body': 'placeholder'}
```

Function handler `handler.handler`. `lifecycle.ignore_changes` includes `filename` and `source_code_hash`. VPC subnets are `slice(private_subnets, 6, 8)`. Security group is lambda-private. Environment `ML_BUCKET_NAME=datafy-prod-ml-image-scan`. Tracing mode `Active`. Log retention 365 days.

IAM: `AWSLambdaVPCAccessExecutionRole`, S3 Get/Put/Delete/List on the ML bucket and its objects, `secretsmanager:GetSecretValue` on `${service_name}*` and `rds!*`, X-Ray put on `*`.

Alarm: namespace `AWS/Lambda`, metric `Errors`, period 60, statistic Sum, threshold 5, one evaluation period, `treat_missing_data = notBreaching`. Actions go to the technical alerts topic.

No `aws_lambda_event_source_mapping`, no Function URL, no API Gateway permission.

## Edge and DNS

ACM module requests `*.${dns}` with `create_before_destroy`. Called twice: default provider (eu-west-1) for the ALB, and `aws.us-east-1` for CloudFront. The waterwheel certificate is a data source (`domain = "*.waterwheel.datafy.co.za"`, `statuses = ["ISSUED"]`, `most_recent = true`).

CloudFront (`modules/aws-so-cdn/cdn.tf`):

- Price class `PriceClass_200`, IPv6 disabled, HTTP/2 and HTTP/3, compress on.
- Default root object `index.html` when origin type is `s3` and the caller does not override it.
- Viewer policy redirect-to-https. Minimum protocol `TLSv1.2_2021` when a verified cert is supplied. The stack sets `ssl_cert_verified = true`.
- S3 origins get an origin access identity and a bucket policy. Methods GET, HEAD, OPTIONS. Cookies and query strings are not forwarded.
- Custom error responses for 403 and 404 return 200 and `/index.html`.
- Response headers policy id `60669652-455b-4ae9-85a4-c4c02393f86c` (AWS managed id, not created here).
- Geo restriction none.
- WAF attached via `web_acl_id`.

WAF log bucket name `aws-waf-logs-${name}`, versioning, AES256, public access block, SSL-only policy, lifecycle expire in 365 days.

Private Route 53 zone resource is commented out. No public hosted zone or ACM DNS validation record is declared. `public_aliases = ["www"]` is passed into the datafy module and is not referenced by `cdn_and_waf.tf` or `load_balancers.tf`.

## Baseline details

Config: name `datafy-prod-config`, prefix `config`, frequency `Six_Hours`, `include_global_resource_types = true`, `is_enabled = true`. Conformance packs from YAML under `modules/aws-so-config/conformance-packs/`: IAM operational best practices, S3 operational best practices, security services.

CloudTrail: name `datafy-prod-cloudtrail-logs`, multi-region, global service events, log file validation, logging enabled. Bucket name appends the account id inside the module.

GuardDuty module receives the access-logs bucket. Finding frequency and feature flags are the module defaults; this document does not restate every optional variable.

Security Hub: `aws_securityhub_account` plus two `aws_securityhub_standards_subscription` resources. No custom insights or disabled controls.

Account settings also create the backup vault on the AWS Backup KMS key (`data.aws_kms_key.backup`) and an IAM role `${name}-aws-backup-role` with `AWSBackupServiceRolePolicyForBackup`.

SNS topic `${name}-technical-alert` is KMS-encrypted. Subscription protocol email. Production endpoint `software-alerts@overdrive.co.za`.

Root-usage filter name is `RootUsage`. Its pattern matches CloudTrail events where `userIdentity.type` is Root, `invokedBy` does not exist, and `eventType` is not `AwsServiceEvent`. The metric transformation inside that filter is named `CloudTrailChanges`, which is the same transformation name used by the CloudTrail-change filter (`name = "CloudTrailChanges"`). Both transformations share `var.alarm_namespace`.

The root alarm sets `metric_name` to the filter resource id, which is the filter name `RootUsage`, not the transformation name `CloudTrailChanges`. The CloudTrail alarm sets `metric_name` to its filter id, `CloudTrailChanges`. So the root alarm watches a metric name that its own filter does not publish, and both filters publish into `CloudTrailChanges`, which is the metric the second alarm reads. Provenance: **Implemented** in `modules/aws-account-settings/settings.tf`. **Requires Confirmation** whether this is an accepted wiring bug.

## Tags

Datafy locals:

```text
Environment = prod
Project     = datafy
ManagedBy   = terraform
Terraform   = true
env         = backup
aws-apn-id  = (partner id string in stacks/datafy/locals.tf)
```

Networking locals omit `env`. RDS module removes `env` at the database resource. Launch-template volumes force `env=backup`.

## Security notes for reviewers

- Catch-all HTTPS listener returns 200. WAF default action is allow.
- NACLs allow all traffic. Security groups are the only network filter, and their egress is all traffic.
- EC2 may connect directly to PostgreSQL, not only through the proxy.
- One IAM statement pins a single instance id and account id.
- Server-artifacts bucket can be force-destroyed.
- Lambda placeholder and EC2 user data do not install the product. A green apply does not mean the three applications are deployed.
- `pgadmin` and storage-upload paths skip parts of the managed WAF on the regional ACL.
- No secret values are committed. The tfvars do contain mailbox addresses and a phone number. `.gitignore` ignores `local.tfvars` and `*.tfvars.json` but does not ignore `*.auto.tfvars`.

## Development process

Git history is the process record. Authors on `main`: Jaco Brits (initial commit), Mohamed Hoosain Ebrahim (bulk of the stack), Dirk van Rensburg (capacity, routing follow-ups, backup, WAF), tanya (31 July 2026 host and certificate updates). One branch, `main`. No pull-request template, no pipeline, no test.

README procedure to add an environment: copy the directory, change the workspace name, edit `.auto.tfvars`. That procedure has not been used for a second environment.

## Diagram versus code

`datafy project redeployment.jpg` is a useful map of intent and a poor map of this revision. Matches: Ireland region, two AZs, public ALB, WAF, Supabase / Water Wheel / Qwen / detection Lambda, S3 for ML scan and Supabase storage, RDS with a proxy, single NAT, S3 gateway endpoint, and the footer services that the baseline actually creates (Security Hub, GuardDuty, Config, CloudTrail, Backup, KMS, Certificate Manager, Secrets Manager, CloudWatch, IAM).

Does not match: API Gateway and a separate Web/Admin Portal path (code uses CloudFront plus an external bucket), SES, Step Functions, a PostgreSQL read replica labelled "slave", and the drawn count of private subnets. CloudFront is absent from the drawing.

## Files a later change is likely to touch

| Change | Files |
|---|---|
| Instance size, port, or health check | `stacks/datafy/locals.tf` |
| Hostname routing | `stacks/datafy/load_balancers.tf` |
| WAF exception | `modules/aws-so-waf/waf.tf`, and `pgadmin_host` in `stacks/datafy/cdn_and_waf.tf` |
| Backup selection | `stacks/datafy/locals.tf` (`env` tag), `modules/aws-so-rds/rds.tf`, `modules/aws-account-settings/settings.tf` |
| CIDR or subnet names | `production.auto.tfvars`, `stacks/networking/networking.tf`, NACL slices |
| Budget mail | `environments/production/common.auto.tfvars` |
