Skip to main content
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 → web
  • ECS Fargate cluster running:
    • api (FastAPI): 2 tasks by default, autoscales 2–10 on CPU
    • web (Vite): 2 tasks by default, autoscales 2–6 on CPU
    • worker (arq background jobs): 1 task by default, autoscales 1–5 on CPU
    • init (one-shot migrations + seeds): runs as a Fargate RunTask whenever image_tag changes
  • RDS Postgres 16: Multi-AZ on prod, encrypted, automated daily backups, Performance Insights, Enhanced Monitoring, log exports
  • ElastiCache Redis 7: 2-node replication group with automatic failover on prod, slow-log to CloudWatch
  • Data 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. The data volume keeps it durable across instance replacements; ClickHouse HA is out of scope for this module.

Prerequisites

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.
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:
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.

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.

Git webhooks on a private install

MCP repository sync publishes a new MCP version as soon as GitHub or GitLab sends a webhook. With alb_scheme = "internal", github.com and gitlab.com cannot reach Observal, so nothing syncs. Turn on the webhook endpoint to fix that without opening the rest of the install:
Terraform then adds an API Gateway HTTP API in front of the existing ALB:
  • One route per provider, POST /api/v1/webhooks/<provider>/mcp/{sync_id}. Everything else, including other methods, other paths and .. segments, gets 404 from API Gateway and never reaches the VPC, so the UI, API and login stay private.
  • A VPC link in the private subnets that connects API Gateway to the ALB’s listener, so there are no public subnets, internet gateway or second load balancer. With enable_tls, API Gateway connects over HTTPS and checks the ALB certificate against domain_name.
  • A security group rule that lets the VPC link, and nothing else new, reach the ALB.
  • Throttling (webhook_throttle_rate_limit, webhook_throttle_burst_limit), access logs in CloudWatch, and WEBHOOK_PUBLIC_URL on the API so the MCP Sync tab shows the public webhook URL.
The webhook URL is the API’s https://<id>.execute-api.<region>.amazonaws.com address, shown by the webhook_public_url output. To use your own hostname instead, set webhook_domain_name (and webhook_route53_zone_id if the public zone differs from route53_zone_id); Terraform adds the certificate, custom domain and DNS record, and turns off the execute-api address. The receiver rejects any delivery that is not authenticated for that listing (a GitHub HMAC signature or the GitLab secret token), and limits each webhook URL to 60 deliveries a minute. Things to know:
  • The ALB must be internal. API Gateway connects to the addresses the ALB’s DNS name resolves to, and an internet-facing ALB resolves to public addresses the VPC link cannot reach; Terraform stops with an error in that case. An internet-facing ALB that is open to the internet does not need this.
  • There is no source IP allowlist. GitHub and GitLab do not sign their source addresses, and the providers’ ranges change, while every delivery is already authenticated. If you need one, put a REST API with a resource policy, or AWS WAF, in front instead.
  • A VPC link with no traffic for 60 days goes inactive, and the next delivery fails while AWS reprovisions it, which takes a few minutes. GitHub does not retry failed deliveries automatically; use Redeliver on the hook, or run the sync by hand from the Sync tab.
  • API Gateway throttling is approximate and meant as a backstop. Requests over the limit get 429 and show as failed in the provider’s delivery log.
  • VPC links are not available in every Availability Zone (for example use1-az3). If terraform apply reports an unsupported zone, use private subnets in other zones.
  • Turning it on adds WEBHOOK_PUBLIC_URL to the API task definition, which rolls the API tasks once.
  • Cost: about $1 per million deliveries; the VPC link itself is free.

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.

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.
  1. Create an S3 bucket (versioned, encrypted) and a DynamoDB table with hash key LockID.
  2. Uncomment and fill the backend block in versions.tf:
  3. Re-run terraform init and answer “yes” when prompted to migrate state.

Cost expectations

Rough monthly baseline in us-east-1 at on-demand rates (May 2026): 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:
  • Switch to remote state (S3 + DynamoDB)
  • Restrict alb_ingress_cidrs to known networks
  • Enable AWS GuardDuty + Config in the account
  • Add CloudWatch alarms on RDS CPUUtilization, FreeableMemory, ECS service CPU, ALB HTTPCode_Target_5XX_Count
  • Attach AWS WAF to the ALB
  • Set transit_encryption_enabled = true on the ElastiCache replication group and switch REDIS_URL to rediss://...
  • Configure Observal SSO. See Authentication and SSO
  • Test the backup and restore procedure end-to-end
  • Replace the GitHub tarball download in user-data.sh.tftpl with an artifact URL you control

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 /readyz on 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/health on port 3001. SSM into the data host and check docker 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.