AWS deployment with Terraform
End state: an Observal install running in your own AWS account, fronted by an Application Load Balancer with HTTPS, with managed Postgres + Redis, ECS Fargate for the stateless app tier, and a single EC2 host for ClickHouse. Prometheus and Grafana are optional through observability_stack.
This is the recommended path for enterprise self-hosting on AWS. If you only want to evaluate Observal, use Docker Compose setup instead.
What gets provisioned
A single terraform apply creates:
VPC with public + private subnets across two availability zones, NAT gateway, VPC flow logs
Application Load Balancer with HTTPS (ACM certificate, DNS-validated) when you supply a domain; HTTP-only otherwise. Path-based rules:
/api/*→ api service, optional/grafana/*→ Grafana, default → webECS Fargate cluster running:
api(FastAPI): 2 tasks by default, autoscales 2–10 on CPUweb(Next.js): 2 tasks by default, autoscales 2–6 on CPUworker(arq background jobs): 1 task by default, autoscales 1–5 on CPUinit(one-shot migrations + seeds): runs as a FargateRunTaskwheneverimage_tagchanges
RDS Postgres 16: Multi-AZ on
prod, encrypted, automated daily backups, Performance Insights, Enhanced Monitoring, log exportsElastiCache Redis 7: 2-node replication group with automatic failover on
prod, slow-log to CloudWatchData tier EC2 (Amazon Linux 2023): single host running ClickHouse on EBS gp3, optional Prometheus and Grafana, ENI with static private IP, internal Route 53 zone for DNS, daily ClickHouse → S3 snapshot via systemd timer
S3 backups bucket: versioned, AES256, lifecycle to STANDARD_IA → GLACIER_IR → expire, TLS-only
CloudWatch log groups: per ECS service, data host, RDS, Redis slow log, VPC flow logs
SSM Parameter Store: generated DB / ClickHouse / SECRET_KEY / optional Grafana passwords, plus pre-built connection URLs injected into ECS tasks
SSM Session Manager: shell access to the data host, no SSH
ClickHouse runs on EC2 because AWS does not offer a managed ClickHouse service. The data volume keeps it durable across instance replacements. For real ClickHouse HA, set clickhouse_mode = "cloud" and point at ClickHouse Cloud.
Prerequisites
AWS account with billing enabled
obvious
Terraform ≥ 1.6
brew install terraform or use tenv
AWS CLI v2
brew install awscli - also used by the one-shot init task runner
shell access into the data host
IAM principal with sufficient rights
(Optional) Route 53 hosted zone
required for HTTPS on a custom domain
(Recommended) S3 bucket + DynamoDB table
remote Terraform state - see Remote state
Quickstart
First-time apply takes 12–15 minutes. RDS provisioning dominates. When it finishes:
Open that URL in your browser. The API and web tasks start a couple of minutes after the ALB targets register; refresh until you see the Observal login page.
A ready-to-apply call of the module lives at infra/terraform/aws/examples/minimal.
Configuration
All inputs live in terraform.tfvars. The defaults are production-shaped; you can apply with very little changed.
Minimal configuration (HTTP, no custom domain)
The install comes up on the ALB's AWS-assigned hostname (e.g. observal-prod-alb-1234.us-east-1.elb.amazonaws.com) over plain HTTP. Useful for evaluation; not for production.
Recommended configuration (HTTPS on your domain)
Terraform requests an ACM certificate, validates it via DNS records in your hosted zone, attaches it to the ALB, and creates the alias A record pointing the domain at the ALB. There is no manual DNS step.
Sizing presets
Instead of tuning 12+ resource variables, pick a preset:
small
$150
1× (0.25 vCPU)
1× (0.25 vCPU)
1× (0.25 vCPU)
t3.medium (50 GB)
db.t4g.micro
cache.t4g.micro
medium
$255
2× (0.5 vCPU)
2× (0.25 vCPU)
1× (0.5 vCPU)
t3.large (100 GB)
db.t4g.small
cache.t4g.micro
large
$600
3× (1 vCPU)
3× (0.5 vCPU)
2× (1 vCPU)
r6i.xlarge (500 GB)
db.r6g.large
cache.r6g.large
Important: Presets vs. individual variables
Presets and individual resource variables (api_cpu, db_instance_class, etc.) are mutually exclusive:
When
sizing = "small|medium|large": all individual resource variables are ignored. The preset values are used unconditionally.When
sizing = "custom": individual variables take effect normally.
If you set sizing = "medium" and also api_cpu = 1024, the medium preset wins — api_cpu has no effect. To override individual values, set sizing = "custom" first.
Custom sizing
Set sizing = "custom" to control each resource independently:
For high-throughput installs (>100 trace events/sec sustained), bump data_instance_type to m6i.xlarge and db_instance_class to db.m6g.large, or move ClickHouse to ClickHouse Cloud (see below).
ClickHouse Cloud instead of EC2
The EC2 data host, EBS volume, internal DNS records, and bundled observability are all skipped. You become responsible for monitoring and dashboards yourself, typically AWS Managed Grafana or Grafana Cloud.
Application options
See Configuration for the meaning of each application setting.
Bring Your Own VPC
Deploy Observal into an existing VPC rather than having Terraform create a new one. Use this when you have Transit Gateway, shared-services VPCs, compliance requirements, or peered networks.
When vpc_id is set, Terraform skips creating VPC, subnets, IGW, NAT gateway, route tables, and VPC flow logs. All other resources (ALB, ECS, RDS, Redis, data host) are created inside your existing VPC.
VPC requirements:
DNS support enabled (
enableDnsSupport = true,enableDnsHostnames = true)Private subnets must have outbound internet access (via NAT Gateway or Transit Gateway) for container image pulls (
ghcr.io) and AWS API endpoints (SSM, CloudWatch, ECR)Public subnets must have an Internet Gateway route (only needed if
alb_scheme = "internet-facing")Subnets should span at least 2 AZs for RDS Multi-AZ and ECS placement
Optional: Bring your own security groups:
For environments with strict firewall policies, supply pre-created SG IDs:
The ALB SG must allow inbound TCP 80/443 from your desired CIDRs. The ECS SG must allow inbound TCP 8000 and 3000 from the ALB SG, with outbound to all.
A full working example lives at infra/terraform/aws/examples/byovpc.
Required IAM permissions
The IAM principal running Terraform needs permission to manage resources across these services. The simplest path is to attach the AWS-managed policies below; for a tighter custom policy, see Hardened IAM policy.
VPC, EC2
AmazonEC2FullAccess
ECS
AmazonECS_FullAccess
RDS
AmazonRDSFullAccess
ElastiCache
AmazonElastiCacheFullAccess
Load balancer
ElasticLoadBalancingFullAccess
Certificates
AWSCertificateManagerFullAccess
DNS
AmazonRoute53FullAccess
IAM (creates ECS + EC2 + RDS roles)
IAMFullAccess
Parameter Store + Session Manager
AmazonSSMFullAccess
S3 (backups bucket)
AmazonS3FullAccess
Logs
CloudWatchLogsFullAccess
Application Auto Scaling
AutoScalingFullAccess
Operating the install
Tail an ECS service
Or get all log group names:
Force a rolling deploy
Re-run migrations / seeds
The init task runs automatically on every image_tag change. To run it manually:
Shell into the data host
Read a generated secret
The Terraform run generates and stores DB_PASSWORD, CLICKHOUSE_PASSWORD, SECRET_KEY, and GRAFANA_ADMIN_PASSWORD, plus pre-built DATABASE_URL, REDIS_URL, and CLICKHOUSE_URL connection strings. ECS injects these into tasks at start; you never paste them in.
Upgrade to a new Observal release
The init task re-runs migrations against the live RDS, then ECS rolls each service over with zero downtime.
Resize the data volume
Destroy
In prod, RDS has deletion_protection = true and skip_final_snapshot = false. Disable both manually before destroy if that is really what you want. The S3 backups bucket also blocks destroy unless backup_bucket_force_destroy = true.
Remote state
By default, Terraform writes state to your laptop (terraform.tfstate). For a real install, configure remote state so the install can be managed by anyone on your team and state isn't lost.
Create an S3 bucket (versioned, encrypted) and a DynamoDB table with hash key
LockID.Uncomment and fill the backend block in
versions.tf:Re-run
terraform initand answer "yes" when prompted to migrate state.
Cost expectations
Rough monthly baseline in us-east-1 at on-demand rates (May 2026):
Fargate api 2× (0.5 vCPU / 1 GB)
$30
Fargate web 2× (0.25 vCPU / 0.5 GB)
$15
Fargate worker 1× (0.5 vCPU / 1 GB)
$15
EC2 t3.large (data host)
$60
RDS db.t4g.small Multi-AZ
$50
ElastiCache (2× cache.t4g.micro)
$25
ALB
$20
NAT Gateway
$33 + egress
EBS gp3 100 GB
$8
S3 backups (1 GB cold)
$0.10
Baseline
~$255
Set environment = "staging" to drop RDS to single-AZ, run a single Redis node, and skip RDS deletion protection, typically halves the bill. Drop worker_desired_count and web_desired_count for further savings.
Production hardening checklist
The defaults are safe but conservative. Before pointing real traffic at this:
Troubleshooting
terraform apply succeeds but the URL returns 502. The api/web tasks are still starting. Watch the service:
The null_resource.run_init step also runs migrations before the api service comes up. If the init task fails, the api service won't start.
Init task fails. Check the /aws/ecs/observal-prod/init log group. The most common failures are:
RDS not yet reachable (transient on first apply; re-running fixes it)
Migration error (look at the entrypoint output)
To re-run by hand: $(terraform output -raw init_run_task_command).
ALB target health is unhealthy.
For
api: health check hits/readyzon port 8000. Tail the api log group for startup errors.For
web: health check hits/on port 3000. Check the web log group.For Grafana: health check hits
/api/healthon port 3001. SSM into the data host and checkdocker compose ps.
ACM certificate stuck in Pending validation. The DNS validation records were not created in your hosted zone. Verify route53_zone_id is correct and that the IAM principal had route53:ChangeResourceRecordSets permission at apply time. Re-run terraform apply after fixing.
RDS storage is full. max_allocated_storage autoscales up to 500 GB by default. Raise db_max_allocated_storage_gb if you have heavier audit-log volume.
For application-level issues (login fails, traces missing) see Troubleshooting.
Last updated
Was this helpful?