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 singleterraform 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 CPUweb(Vite): 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 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
Prerequisites
Quickstart
infra/terraform/aws/examples/minimal.
Configuration
All inputs live interraform.tfvars. The defaults are production-shaped; you can apply with very little changed.
Minimal configuration (HTTP, no custom domain)
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)
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.
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
Setsizing = "custom" to control each resource independently:
data_instance_type to m6i.xlarge and db_instance_class to db.m6g.large.
Application options
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.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
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. Withalb_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:
- One route per provider,
POST /api/v1/webhooks/<provider>/mcp/{sync_id}. Everything else, including other methods, other paths and..segments, gets404from 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 againstdomain_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, andWEBHOOK_PUBLIC_URLon the API so the MCP Sync tab shows the public webhook URL.
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
429and show as failed in the provider’s delivery log. - VPC links are not available in every Availability Zone (for example
use1-az3). Ifterraform applyreports an unsupported zone, use private subnets in other zones. - Turning it on adds
WEBHOOK_PUBLIC_URLto 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
Force a rolling deploy
Re-run migrations / seeds
The init task runs automatically on everyimage_tag change. To run it manually:
Shell into the data host
Read a generated secret
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
Resize the data volume
Destroy
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 inus-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_cidrsto known networks - Enable AWS GuardDuty + Config in the account
- Add CloudWatch alarms on RDS
CPUUtilization,FreeableMemory, ECS service CPU, ALBHTTPCode_Target_5XX_Count - Attach AWS WAF to the ALB
- Set
transit_encryption_enabled = trueon the ElastiCache replication group and switchREDIS_URLtorediss://... - 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.tftplwith an artifact URL you control
Troubleshooting
terraform apply succeeds but the URL returns 502. The api/web tasks are still starting. Watch the service:
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)
$(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.
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.
