Self-hosted Umami analytics on AWS EC2 with automatic TLS via Let's Encrypt.
┌─────────────────────────────────────────────────────┐
│ Auto Scaling Group (min=max=1) │
│ ┌───────────────────────────────────────────────┐ │
│ │ EC2 t4g.nano (Amazon Linux 2023) │ │
Internet │ │ │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │
│ HTTPS (443) │ │ │ Docker Compose │ │ │
▼ │ │ │ │ │ │
┌───────────┐ │ │ │ ┌─────────┐ ┌─────────┐ ┌───────┐ │ │ │
│ Elastic │───────────────────┼──┼──┼─▶│ Caddy │──▶│ Umami │──▶│ Postgres│ │ │ │
│ IP │ │ │ │ │ (TLS) │ │ (app) │ │ (db) │ │ │ │
└───────────┘ │ │ │ └─────────┘ └─────────┘ └───┬────┘ │ │ │
│ │ │ │ │ │ │
│ │ └──────────────────────────────────┼───────┘ │ │
│ │ │ │ │
│ └─────────────────────────────────────┼──────────┘ │
│ │ │
└────────────────────────────────────────┼─────────────┘
│
│ mount
▼
┌───────────────┐
│ EBS Volume │
│ (gp3 10GB) │
│ │
│ /data/ │
│ ├── postgres │
│ └── caddy │
└───────────────┘
- Minimal footprint: t4g.nano instance (~$3/month) with ARM64 architecture
- Persistent data: External EBS volume survives instance recycling
- Auto-recovery: ASG automatically replaces unhealthy instances
- Automatic TLS: Caddy handles Let's Encrypt certificate provisioning
- Ad-blocker bypass: Configurable tracker script name
- SSM access: No SSH keys needed, use AWS Systems Manager for access
- 2GB swap: Configured swap file to handle memory pressure on t4g.nano
- CloudWatch monitoring: Optional email alerts for CPU, memory, disk, and Docker container health
- Route 53 integration: Optional automatic DNS record creation
| Resource | Monthly Cost |
|---|---|
| t4g.nano (eu-west-1) | ~$3.07 |
| EBS gp3 10GB (data) | ~$0.80 |
| EBS gp3 8GB (root) | ~$0.64 |
| Elastic IP (attached) | $0.00 |
| CloudWatch Alarms (5x, optional) | ~$0.50 |
| Total | ~$4-5/month |
- AWS account with appropriate permissions
- Terraform >= 1.0
- Domain name with DNS you can configure
By default, Terraform stores state locally. For team collaboration or CI/CD, configure an S3 backend:
-
Create an S3 bucket for state:
aws s3 mb s3://your-terraform-state-bucket --region us-east-1 aws s3api put-bucket-versioning --bucket your-terraform-state-bucket \ --versioning-configuration Status=Enabled
-
Copy and configure the backend:
cp backend.tf.example backend.tf # Edit backend.tf with your bucket name -
Initialize with the new backend:
terraform init
-
Copy the example tfvars file:
cp terraform.tfvars.example terraform.tfvars
-
Generate an app secret:
openssl rand -hex 32
-
Edit
terraform.tfvars:domain = "stats.example.com" letsencrypt_email = "[email protected]" app_secret = "your-generated-secret"
-
Initialize and apply:
terraform init terraform apply
-
Create DNS record: Point your domain to the Elastic IP shown in the outputs.
-
Access Umami:
- URL:
https://your-domain.com - Default credentials:
admin/umami - Change the password immediately!
- URL:
| Name | Description | Default |
|---|---|---|
domain |
Domain for Umami | (required) |
letsencrypt_email |
Email for Let's Encrypt | (required) |
app_secret |
Secret for Umami sessions | (required) |
instance_type |
EC2 instance type | t4g.nano |
region |
AWS region | eu-west-1 |
root_volume_size |
Root volume size (GB) | 8 |
data_volume_size |
Data volume size (GB) | 10 |
tracker_script_name |
Custom script name | data |
vpc_id |
VPC ID (optional) | default VPC |
subnet_id |
Subnet ID (optional) | first subnet |
allowed_ssh_cidr |
CIDR for SSH access | null |
route53_zone_name |
Route 53 zone for DNS record | null |
alert_email |
Email for CloudWatch alerts | null |
cpu_threshold_percent |
CPU alert threshold | 80 |
memory_threshold_percent |
Memory alert threshold | 80 |
disk_threshold_percent |
Disk alert threshold | 80 |
| Name | Description |
|---|---|
elastic_ip |
The Elastic IP address |
url |
Full Umami URL |
dns_record |
DNS record to create |
asg_name |
ASG name for management |
recycle_instance_command |
Command to recycle the instance |
To apply updates or recover from issues:
aws autoscaling terminate-instance-in-auto-scaling-group \
--instance-id $(aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names umami \
--query 'AutoScalingGroups[0].Instances[0].InstanceId' \
--output text) \
--no-should-decrement-desired-capacityaws ssm start-session --target <instance-id>aws ssm start-session --target <instance-id>
# Then on the instance:
sudo cat /var/log/user-data.logaws ssm start-session --target <instance-id>
# Then on the instance:
cd /opt/umami && docker-compose ps
docker-compose logs -f-
Check ASG activity:
aws autoscaling describe-scaling-activities --auto-scaling-group-name umami
-
Check user-data log via SSM
- Ensure DNS is pointing to the Elastic IP
- Check Caddy logs:
docker-compose logs caddy - Let's Encrypt rate limits: wait 1 hour if you've made too many requests
- Data is stored on external EBS volume at
/data/postgres - Volume survives instance termination
- Check PostgreSQL logs:
docker-compose logs db
When alert_email is provided, the module creates CloudWatch alarms that send email notifications via SNS:
| Alarm | Trigger |
|---|---|
| CPU High | CPU > 80% for 10 minutes |
| Memory High | Memory > 80% for 10 minutes |
| Disk High (root) | Root disk > 80% |
| Disk High (data) | Data disk > 80% |
| Docker Unhealthy | < 3 containers running |
Important: You must confirm the SNS email subscription by clicking the link in the confirmation email.
aws cloudwatch list-metrics --namespace CWAgent --region eu-west-1aws cloudwatch describe-alarms --alarm-name-prefix umami --region eu-west-1 \
--query 'MetricAlarms[*].[AlarmName,StateValue]' --output tableThis module is brought to you by LeanerCloud. We help companies reduce their cloud costs using a mix of services and tools such as AutoSpotting.
If your company is operating at scale on AWS and would like to reduce your cloud costs, please get in touch using our contact form.
MIT License - see LICENSE file.