Terraform module to deploy imgproxy on AWS ECS with EC2 instances (Bottlerocket), behind an Application Load Balancer, with optional CloudFront, OpenTelemetry monitoring and S3 integration.
The module creates the required resources:
- ECS cluster, service and task definition on an EC2 Auto Scaling Group with an ECS capacity provider, On-Demand or Spot (
spot) - Launch template with Bottlerocket, encrypted volumes and IMDSv2 only
- Application Load Balancer with ACM certificates and HTTP to HTTPS redirect
- Security groups
- Autoscaling rules on imgproxy's own metrics
- IAM execution and task roles (or use your own)
- CloudFront distribution (optional), with the load balancer locked to it
- OpenTelemetry (ADOT) sidecar for X-Ray and CloudWatch (optional)
Also supported: request signing, imgproxy Pro and private registries through Secrets Manager, S3 as image source and result cache (Pro). Secure by default (details).
module "imgproxy" {
source = "imgproxy/ecs-ec2-imgproxy/aws"
version = "~> 0.1"
service_name = "imgproxy"
# Instances in private subnets, internet-facing ALB in public subnets
vpc_id = "vpc-0123456789abcdef0"
subnet_ids = ["subnet-0aaa...", "subnet-0bbb..."] # private: the instances
alb_subnet_ids = ["subnet-0ccc...", "subnet-0ddd..."] # public: the ALB
# HTTPS on the ALB (point a DNS record for the domain at it)
alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/..."]
keys_secret = { arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:imgproxy-keys-AbCdEf" }
salts_secret = { arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:imgproxy-salts-AbCdEf" }
spot = true # run the instances on EC2 Spot to save cost
spot_instance_types = ["c7g.medium", "m6g.medium"]
}Requires Terraform >= 1.5, an authenticated AWS CLI with sufficient permissions, and an existing VPC with subnets: the module creates no networking (see Networking).
Start with insecure and follow the "Next" link at the end of each README; every step adds one feature.
- insecure - Minimal deployment without request signing (development only)
- signed - URL signing, plus the production setup
- s3 - Private S3 bucket as the image source
- cdn - CloudFront in front of the load balancer
- pro - imgproxy Pro with license key and S3 result cache
- complete - Pro, monitoring and S3 together
The examples run in the default VPC over plain HTTP, so they set assign_public_ip = true and allow_insecure_http = true. Testing only.
Each insecure choice is an explicit, named opt-in:
| Default | Opt-in | Acceptable for |
|---|---|---|
| Instances have no public IP | assign_public_ip = true |
Public subnets such as the default VPC. Each instance is then addressable by IP (the security group still admits only the ALB on the ECS dynamic port range) |
The ALB requires HTTPS (alb_certificate_arns) |
allow_insecure_http = true |
Testing: traffic, signed URLs and tokens are unencrypted (with CloudFront, the origin secret header too) |
| Requests must be signed | insecure = true |
Development: anyone can use your imgproxy and, with source_buckets, read those buckets |
Without a certificate and without allow_insecure_http = true, the module refuses to plan.
The deployer needs permissions for ECS, EC2 (launch templates, security groups), Auto Scaling, the load balancer, Application Auto Scaling, CloudWatch alarms and log groups, and CloudFront. Two example policies are provided; before using either, replace 123456789012 with your AWS account ID and the imgproxy role-name prefix with your service_name (the module names its roles <service_name>-execution-*, <service_name>-task-* and <service_name>-instance-*).
- example-permission-set.json: the module creates the task and instance roles, so the deployer needs IAM access, limited to roles named
imgproxy-*. Treat this identity as privileged. - example-permission-set-existing-roles.json: you create the task roles and pass them as
execution_role_arnandtask_role_arn; the deployer then needs onlyiam:PassRoleon them. The EC2 instance role and profile are always created by the module (<service_name>-instance-*).
Neither policy reads secret values or covers the Terraform state backend. They were not applied end to end against AWS, so adjust them if an apply fails with AccessDenied.
Run the instances in private subnets and keep only the ALB public, as in Usage.
alb_subnet_idsmust be public subnets. When unset, the ALB goes intosubnet_idsand is unreachable from the internet.internal_alb = truemakes it reachable only from the VPC and connected networks.- Private instances need an outbound route to pull from
ghcr.io(ordocker.imgproxy.pro), reach the ECS control plane and fetch images: use a NAT gateway, or tasks fail withCannotPullContainerErrorand instances never join the cluster. With S3 sources and an ECR image, VPC endpoints can replace it: S3 gateway plus interface endpoints forecr.api,ecr.dkr,secretsmanagerandlogs. - The instance security group admits only the ALB, on the ECS dynamic port range (32768-65535). Instances have no SSH key; use AWS Systems Manager Session Manager. Add VPN or bastion rules through
instances_security_group_idoradditional_security_group_ids.
secrets maps extra environment variables (named as in imgproxy's configuration) to Secrets Manager secrets. version is optional (the current version is used by default). A module-created execution role can read every secret you pass.
secrets = {
IMGPROXY_SENTRY_DSN = { arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:sentry-dsn-AbCdEf" }
}The instances pull ghcr.io/imgproxy/imgproxy, or docker.imgproxy.pro/imgproxy with pro = true. For your own image, set repo (without tag) and tag (a version, or a sha256: digest):
repo = "registry.example.com/imgproxy-custom"
repo_credentials_secret = { arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:registry-AbCdEf" }
tag = "my-tag"- Public images and ECR in your account need no credentials. Other private registries need
repo_credentials_secret, a JSON secret withusernameandpasswordkeys (for docker.imgproxy.pro the username isimgproxy). A customexecution_role_arnmust be granted ECR pull orsecretsmanager:GetSecretValueyourself. pro = trueonly selects the defaultrepo; it has no effect whenrepois set.
Instances and tasks run on ARM64 (Graviton, c6g.medium) by default; for images without linux/arm64, set cpu_architecture = "X86_64" and an x86 instance_type (e.g. c6i.large).
spot = trueruns the Auto Scaling Group on EC2 Spot capacity (up to 90% cheaper);spot_instance_typeswidens the pool andspot_on_demand_base/spot_on_demand_percentagekeep part of it On-Demand. ECS drains instances about to be reclaimed.- Instances scale between
min_instancesandmax_instancesthrough the ECS capacity provider (target_capacity,managed_termination_protection, optionalwarm_pool); keeptask_max_count <= max_instances, since each instance runs one task by default. - Tasks scale between
task_min_countandtask_max_counton imgproxy'sWorkersUtilizationmetric (out above 80%, in below 50%). Terraform ignores later changes to the task count. enable_monitoring = trueadds an ADOT sidecar (OTLP onlocalhost:4318) exporting traces to X-Ray and metrics and logs to CloudWatch. It reserves 256Mi of task memory; replace its configuration withotel_collector_config.source_bucketsandcache_bucket(Pro) grant IAM permissions on existing buckets; the module does not create them. SetIMGPROXY_S3_REGIONinenvironmentwhen a bucket is outside the default region./healthis served on port 8080. Logs go to the CloudWatch group/aws/ecs/<service_name>, kept forlog_retention_days(365 by default).
enable_cloudfront = true puts CloudFront in front of the ALB (create_alb must stay true), on its *.cloudfront.net domain or on yours through cloudfront_domains and cloudfront_certificate_arn (issued in us-east-1).
The ALB is locked to CloudFront: its security group admits only CloudFront's IPs, and the listener returns 403 without the module-generated X-Origin-Verify header. That only holds if the hop is encrypted, so HTTPS is required:
enable_cloudfront = true
alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/..."] # covers origin.example.com
cloudfront_origin_domain = "origin.example.com"CloudFront validates the ALB certificate against the origin's name, so cloudfront_origin_domain must be a dedicated name covered by the ALB certificate, pointing at the ALB (alb_dns_name and alb_zone_id outputs), and different from cloudfront_domains. For testing, allow_insecure_http = true makes the hop plain HTTP, which lets anyone who captures the header bypass the lock.
Cache keys follow your imgproxy configuration (Accept with format detection, client hints, IMGPROXY_CACHE_KEY_HEADERS and IMGPROXY_CACHE_KEY_COOKIES). secret_secret cannot be combined with CloudFront, which does not forward Authorization. Query strings are not forwarded either, so percent-encode any ? in plain source URLs.
modules/aws-common and modules/imgproxy-common are generated copies from imgproxy/terraform-common. Do not edit them here: change them upstream and run its sync.sh.
| Name | Version |
|---|---|
| terraform | >= 1.5 |
| aws | ~> 5.0 |
| Name | Version |
|---|---|
| aws | ~> 5.0 |
| Name | Source | Version |
|---|---|---|
| aws_common | ./modules/aws-common | n/a |
| imgproxy_common | ./modules/imgproxy-common | n/a |
| Name | Type |
|---|---|
| aws_appautoscaling_policy.scale_in | resource |
| aws_appautoscaling_policy.scale_out | resource |
| aws_appautoscaling_target.ecs | resource |
| aws_autoscaling_group.main | resource |
| aws_cloudwatch_metric_alarm.concurrency_high | resource |
| aws_cloudwatch_metric_alarm.concurrency_low | resource |
| aws_ecs_capacity_provider.main | resource |
| aws_ecs_cluster.main | resource |
| aws_ecs_cluster_capacity_providers.main | resource |
| aws_ecs_service.main | resource |
| aws_ecs_task_definition.main | resource |
| aws_iam_instance_profile.main | resource |
| aws_iam_role.instance | resource |
| aws_iam_role_policy_attachment.instance_ecs | resource |
| aws_iam_role_policy_attachment.instance_ssm | resource |
| aws_launch_template.main | resource |
| aws_security_group.instances | resource |
| aws_caller_identity.current | data source |
| aws_region.current | data source |
| aws_ssm_parameter.bottlerocket_ami | data source |
| Name | Description | Type | Required |
|---|---|---|---|
| subnet_ids | Subnet IDs for EC2 instances. Use private subnets with a NAT gateway (recommended), or public subnets with assign_public_ip = true. | list(string) |
yes |
| vpc_id | VPC ID to deploy the ECS cluster and ALB into. | string |
yes |
| additional_security_group_ids | Additional security group IDs to attach to EC2 instances. | list(string) |
no |
| alb_certificate_arns | ACM certificate ARNs for the HTTPS listener. Required unless allow_insecure_http = true. Creates a port 443 HTTPS listener; the first ARN is the primary certificate and additional ARNs are attached as SNI extras. | list(string) |
no |
| alb_deletion_protection | Enable deletion protection on the ALB. | bool |
no |
| alb_deregistration_delay | Seconds the ALB waits before deregistering a target during deployments and scale-in. Must be >= alb_idle_timeout to avoid in-flight connection resets. | number |
no |
| alb_idle_timeout | ALB connection idle timeout in seconds. | number |
no |
| alb_redirect_http_to_https | Redirect HTTP to HTTPS on the ALB. Requires alb_certificate_arns to be set. | bool |
no |
| alb_subnet_ids | Subnet IDs for the Application Load Balancer. When null, uses subnet_ids. Set to public subnets when instances are in private subnets. | list(string) |
no |
| allow_insecure_http | Allow the ALB to serve plain HTTP only, that is, without alb_certificate_arns. For testing only: requests, signed URLs and tokens travel unencrypted. With CloudFront it also makes CloudFront reach the ALB over plain HTTP, so the origin secret header and all traffic cross the network in clear text. | bool |
no |
| allow_public_access | Allow public internet access to the ALB (0.0.0.0/0 on port 80/443). | bool |
no |
| ami_id | Bottlerocket ECS-optimized AMI ID. When null, the latest Bottlerocket AMI is resolved from SSM automatically. | string |
no |
| assign_public_ip | Assign a public IP to EC2 instances. Off by default: run the instances in private subnets with a NAT gateway (or VPC endpoints). Set to true only when the subnets are public, such as those of the default VPC: instances then need it to pull the image, and every instance is directly addressable by IP (the instance security group still only accepts the ALB on the ECS dynamic port range). | bool |
no |
| cache_bucket | S3 bucket name for imgproxy result cache (imgproxy Pro only). The task role is granted full object access. | string |
no |
| cdn_default_ttl | Default CloudFront TTL in seconds. Set to 0 to let CloudFront respect origin Cache-Control headers. | number |
no |
| cdn_max_ttl | Maximum CloudFront TTL in seconds, regardless of origin headers. | number |
no |
| cdn_min_ttl | Minimum CloudFront TTL in seconds. Set to a positive value to force a floor TTL regardless of origin headers. | number |
no |
| cdn_redirect_http_to_https | Redirect HTTP to HTTPS at the CloudFront level. | bool |
no |
| cdn_waf_web_acl_id | WAF Web ACL ARN to associate with the CloudFront distribution. | string |
no |
| cloudfront_certificate_arn | ACM certificate ARN for CloudFront (must be in us-east-1). Required when cloudfront_domains is set. | string |
no |
| cloudfront_domains | Custom domain aliases for the CloudFront distribution. Requires cloudfront_certificate_arn. | list(string) |
no |
| cloudfront_origin_domain | Domain name CloudFront uses to reach the ALB over HTTPS, for example 'origin.example.com'. Create a DNS record for it that points at the ALB (see the alb_dns_name and alb_zone_id outputs) and cover it with a certificate in alb_certificate_arns. Required when enable_cloudfront = true and alb_certificate_arns is set, because CloudFront validates the ALB certificate against this name. Must differ from cloudfront_domains. | string |
no |
| cloudfront_origin_shield_region | AWS region to enable CloudFront Origin Shield (e.g. "us-east-1"). Adds an extra caching tier between edge locations and the ALB, reducing origin load. When null, Origin Shield is disabled. | string |
no |
| cpu | vCPUs allocated to the imgproxy container (cpu × 1024 CPU units). Sets IMGPROXY_WORKERS default. | number |
no |
| cpu_architecture | CPU architecture for the container and EC2 instances. ARM64 (Graviton: c6g/c7g/c8g) is recommended: lower cost, better throughput. Must match the instance type family and the image platform. | string |
no |
| create_alb | Create an Application Load Balancer for the ECS service. | bool |
no |
| data_volume_size | Size in GiB of the encrypted Bottlerocket data volume (container images and ephemeral storage). | number |
no |
| deployment_minimum_healthy_percent | Minimum percentage of healthy tasks during a rolling deployment. 100 = zero-downtime (new task starts before old stops). 0 = fastest deploy (brief outage). With task_min_count = 1, a value of 50 allows a zero-task gap. | number |
no |
| desired_count | Desired number of ECS tasks. Defaults to task_min_count. Changes after initial creation are ignored (managed by ECS auto-scaling). | number |
no |
| enable_cloudfront | Create a CloudFront distribution in front of the ALB for global edge caching. Requires create_alb = true. | bool |
no |
| enable_monitoring | Enable the AWS Distro for OpenTelemetry (ADOT) sidecar container to export traces and metrics to X-Ray and CloudWatch. Automatically configures imgproxy OTEL environment variables. | bool |
no |
| environment | Additional environment variables passed to imgproxy (e.g. IMGPROXY_MAX_SRC_RESOLUTION). | map(string) |
no |
| execution_role_arn | ARN of an existing IAM role to use as the ECS task execution role. When null, a role is created with ECR pull and Secrets Manager access. | string |
no |
| insecure | Disable URL signature checks. When true, key and salt are not required. | bool |
no |
| instance_type | EC2 instance type for ECS cluster nodes. | string |
no |
| internal_alb | Create an internal (non-internet-facing) ALB. Use for private deployments accessed via VPC only. | bool |
no |
| keys_secret | Secrets Manager ARN for imgproxy signing keys (comma-separated hex-encoded values). Required when insecure = false. Generate: openssl rand -hex 64. Sets IMGPROXY_KEY. | object({ arn = string version = optional(string, "") }) |
no |
| log_retention_days | CloudWatch log group retention in days. | number |
no |
| managed_termination_protection | Enable ECS managed termination protection on ASG instances. When true, ECS prevents scale-in of instances that have running tasks. Disable for faster terraform destroy in dev/test environments. | bool |
no |
| max_instances | Maximum number of EC2 instances in the Auto Scaling Group. | number |
no |
| memory | Memory limit for the imgproxy container. Accepts Mi or Gi suffix (e.g. '512Mi', '2Gi'). Must fit on the instance type together with the OTEL sidecar when enabled. | string |
no |
| min_instances | Minimum number of EC2 instances in the Auto Scaling Group. | number |
no |
| otel_collector_config | OpenTelemetry Collector configuration YAML passed to the ADOT sidecar via AOT_CONFIG_CONTENT. When null, a default config exporting to X-Ray and CloudWatch is used. | string |
no |
| pro | Use imgproxy Pro (docker.imgproxy.pro) instead of the community image (ghcr.io) as the default repo. Has no effect when repo is set. | bool |
no |
| pro_license_key_secret | Secrets Manager ARN for the imgproxy Pro license key. Sets IMGPROXY_LICENSE_KEY. | object({ arn = string version = optional(string, "") }) |
no |
| repo | Container repository to pull imgproxy from, without tag (e.g. 'registry.example.com/imgproxy' or '123456789012.dkr.ecr.us-east-1.amazonaws.com/imgproxy'). When null, 'pro' selects docker.imgproxy.pro/imgproxy or ghcr.io/imgproxy/imgproxy. ECR repositories in the same account are pulled with the execution role and need no credentials; for any other private registry set repo_credentials_secret. | string |
no |
| repo_credentials_secret | Secrets Manager secret with the credentials for the registry in 'repo' (or docker.imgproxy.pro when 'repo' is not set). Must be a JSON secret with 'username' and 'password' keys (ECS repositoryCredentials). Required when pro = true and repo is not set; the execution role is granted read access to it. | object({ arn = string }) |
no |
| salts_secret | Secrets Manager ARN for imgproxy signing salts (must match number of keys). Required when insecure = false. Sets IMGPROXY_SALT. | object({ arn = string version = optional(string, "") }) |
no |
| secret_secret | Secrets Manager ARN for the authorization token. When set, requests must include Authorization: Bearer . Sets IMGPROXY_SECRET. | object({ arn = string version = optional(string, "") }) |
no |
| secrets | Map of environment variable names to Secrets Manager ARNs. Example: {IMGPROXY_NEW_RELIC_KEY = {arn = "arn:aws:secretsmanager:us-east-1:123:secret:newrelic-key"}}. | map(object({ arn = string version = optional(string, "") })) |
no |
| service_name | Prefix applied to every resource name. | string |
no |
| signature_size | Number of bytes from the key/salt used for signing. Sets IMGPROXY_SIGNATURE_SIZE. | number |
no |
| source_buckets | S3 bucket names for imgproxy source images. The task role is granted s3:GetObject, s3:GetObjectVersion and s3:ListBucket on each. Enables IMGPROXY_USE_S3. | list(string) |
no |
| source_url_encryption_key_secret | Secrets Manager ARN for the source URL encryption key. Sets IMGPROXY_SOURCE_URL_ENCRYPTION_KEY. | object({ arn = string version = optional(string, "") }) |
no |
| spot | Use EC2 Spot instances with price-capacity-optimized allocation. Set spot_instance_types to provide multiple instance types for better availability. | bool |
no |
| spot_instance_types | Additional EC2 instance types for the Spot pool alongside instance_type. More types = better availability and fewer interruptions. Only used when spot = true. | list(string) |
no |
| spot_on_demand_base | Minimum number of On-Demand instances to maintain regardless of spot_on_demand_percentage. Only used when spot = true. | number |
no |
| spot_on_demand_percentage | Percentage of instances above spot_on_demand_base to run as On-Demand (0 = all Spot, 100 = all On-Demand). Only used when spot = true. | number |
no |
| tag | imgproxy image tag (version). | string |
no |
| tags | Tags applied to all created resources. | map(string) |
no |
| target_capacity | Target capacity utilization percentage for ECS managed scaling (1–100). Values below 100 keep spare EC2 capacity for faster task placement. | number |
no |
| task_max_count | Maximum number of ECS tasks for Application Auto Scaling. | number |
no |
| task_min_count | Minimum number of ECS tasks for Application Auto Scaling. | number |
no |
| task_role_arn | ARN of an existing IAM role to use as the ECS task role. When null, a role is created with S3 and monitoring permissions as needed. | string |
no |
| timeout | Request timeout in seconds. Sets IMGPROXY_TIMEOUT. | number |
no |
| trusted_signatures_secret | Secrets Manager ARN for trusted signature prefixes. imgproxy Pro only. Sets IMGPROXY_TRUSTED_SIGNATURES. | object({ arn = string version = optional(string, "") }) |
no |
| warm_pool | Create an ASG warm pool of pre-initialized instances for faster scale-out. Incompatible with spot = true. | bool |
no |
| workers | Number of concurrent requests per container. Sets IMGPROXY_WORKERS. Defaults to cpu × 2. | number |
no |
| Name | Description |
|---|---|
| alb_dns_name | DNS name of the Application Load Balancer. |
| alb_security_group_id | Security group ID of the Application Load Balancer (null without an ALB). Use it to add ingress rules, e.g. for an internal ALB. |
| alb_zone_id | Hosted zone ID of the ALB. Use with Route 53 alias records. |
| autoscaling_group_name | Name of the Auto Scaling Group running the ECS instances. |
| cloudfront_domain_name | CloudFront distribution domain name. |
| cloudfront_hosted_zone_id | CloudFront hosted zone ID. Use with Route 53 alias records. |
| cluster_arn | ECS cluster ARN. |
| cluster_name | ECS cluster name. |
| execution_role_arn | ARN of the IAM role used by the ECS agent to start tasks. |
| instances_security_group_id | Security group ID attached to the ECS EC2 instances. |
| log_group_name | CloudWatch Log Group name for imgproxy and OTEL sidecar logs. |
| service_name | ECS service name. |
| task_role_arn | ARN of the IAM role used by the imgproxy task at runtime. |
| url | imgproxy endpoint URL. CloudFront domain when enable_cloudfront = true, otherwise ALB DNS name. |
MIT