Deploy RunsOn self-hosted GitHub Actions runners on AWS with Terraform/OpenTofu.
- Usage
- Versioning
- Resource Tags
- Architecture
- Examples
- Requirements
- Providers
- Modules
- Resources
- Inputs
- Outputs
- License
terraform { required_version = ">= 1.5.7" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } provider "aws" { region = "us-east-1" } # Get available AZs data "aws_availability_zones" "available" { state = "available" } # VPC Module - Creates networking infrastructure module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0" name = "runs-on-vpc" cidr = "10.0.0.0/16" azs = slice(data.aws_availability_zones.available.names, 0, 3) private_subnets = ["10.0.128.0/20", "10.0.144.0/20", "10.0.160.0/20"] public_subnets = ["10.0.0.0/20", "10.0.16.0/20", "10.0.32.0/20"] # NAT Gateway for private subnets (required for private networking) # enable_nat_gateway = true # single_nat_gateway = true enable_dns_hostnames = true enable_dns_support = true } # RunsOn Module - Deploys RunsOn infrastructure with smart defaults module "runs-on" { source = "runs-on/runs-on/aws" version = "v2.11.0-r1" # Required: GitHub and License github_organization = "my-org" license_key = "your-license-key" email = "alerts@example.com" # Required: Network configuration (BYOV - Bring Your Own VPC) vpc_id = module.vpc.vpc_id public_subnet_ids = module.vpc.public_subnets private_subnet_ids = module.vpc.private_subnets }The module assumes you have your own VPC already configured.
This module follows a versioning scheme that maps to the main RunsOn application version:
v{MAJOR}.{MINOR}.{PATCH}-r{REVISION} v{MAJOR}.{MINOR}.{PATCH}- Matches the compatible RunsOn application version-r{REVISION}- Independent Terraform module revision (r1, r2, r3, etc.)
Examples:
v2.11.0-r1- First Terraform release for RunsOn v2.11.0v2.11.0-r2- Second Terraform release for RunsOn v2.11.0 (bug fixes, improvements)v2.12.0-r1- First Terraform release for RunsOn v2.12.0
When upgrading, check:
- The RunsOn version changelog at runs-on.com/changelog
- The Terraform module release notes in this repository
To use this module from a specific git branch (e.g. main):
module "runs-on" { source = "git::https://github.com/runs-on/terraform-aws-runs-on.git?ref=main" github_organization = "my-org" license_key = "your-license-key" email = "alerts@example.com" vpc_id = "vpc-xxxxxxxx" public_subnet_ids = ["subnet-pub1", "subnet-pub2", "subnet-pub3"] }Replace main with any branch name, tag, or commit SHA
All resources are tagged with runs-on-stack-name for discovery by the CLI. Key resources also have a runs-on-resource tag for identification:
apprunner-service- App Runner serviceconfig-bucket- Configuration S3 bucketcache-bucket- Cache S3 bucketlogging-bucket- Logging S3 bucketec2-log-group- EC2 instances CloudWatch log group
Do not remove these tags.
flowchart TB subgraph AWS["Your AWS Infrastructure"] subgraph Core["Core Infrastructure (Basic)"] direction TB AppRunner["App Runner<br/><i>RunsOn Service</i>"] SQS["SQS Queues<br/><i>Job Processing</i>"] DynamoDB["DynamoDB<br/><i>State & Locks</i>"] S3["S3 Buckets<br/><i>Config & Cache</i>"] EC2["EC2 Launch Templates<br/><i>Linux & Windows</i>"] IAM["IAM Roles<br/><i>Permissions</i>"] subgraph Monitoring["Monitoring"] SNS["SNS Topics<br/><i>Alerts</i>"] CWLogs["CloudWatch Logs"] CWDashboard["CloudWatch Dashboard<br/>"] end end subgraph Optional["Optional Plug-ins"] direction TB EFS["EFS<br/><i>Shared Storage</i>"] ECR["ECR<br/><i>Image Cache</i>"] Private["Private Networking<br/><i>NAT Gateway</i>"] OTEL["OTEL / Prometheus<br/><i>Metrics Export</i>"] end VPC["VPC & Subnets"] end GitHub["GitHub"]:::github Alerts["Slack / Email"] GitHub <-->|API & webhooks| AppRunner AppRunner --> SQS AppRunner --> DynamoDB AppRunner --> S3 AppRunner -->|launches| EC2 EC2 --> IAM AppRunner --> CWLogs EC2 --> CWLogs SNS -.-> Alerts VPC -.->|network| Core EFS -.->|enable_efs| EC2 ECR -.->|enable_ecr| EC2 Private -.->|private_mode| EC2 OTEL -.->|otel_exporter_endpoint| AppRunner style AWS fill:#8881,stroke:#888 style Core fill:#0969da22,stroke:#0969da style Optional fill:#d2992222,stroke:#d29922 style Monitoring fill:#23863622,stroke:#238636 classDef github fill:#8b5cf6,stroke:#7c3aed,color:#fff,stroke-width:2px Tip
Cost Estimates:
- RunsOn base: ~$3/mo (App Runner)
- EFS (optional): ~$0.30/GB-month for storage
- ECR (optional): ~$0.10/GB-month for storage
- Runners: EC2 costs vary by instance type and usage (pay only for what you use)
- S3 Gateway endpoints: free
When using private networking, keep in mind you might incur the following costs:
- NAT Gateway: ~$32/mo per gateway + data transfer charges
- VPC Endpoints: ~$7/mo per interface endpoint (e.g. EC2, ECR) + data transfer charges
Standard deployment with smart defaults:
module "runs-on" { source = "runs-on/runs-on/aws" version = "v2.11.0-r1" github_organization = "my-org" license_key = "your-license-key" email = "alerts@example.com" vpc_id = "vpc-xxxxxxxx" public_subnet_ids = ["subnet-pub1", "subnet-pub2", "subnet-pub3"] }Enable private networking for static egress IPs (requires NAT Gateway):
module "runs-on" { source = "runs-on/runs-on/aws" version = "v2.11.0-r1" github_organization = "my-org" license_key = "your-license-key" email = "alerts@example.com" vpc_id = "vpc-xxxxxxxx" public_subnet_ids = ["subnet-pub1", "subnet-pub2", "subnet-pub3"] private_subnet_ids = ["subnet-priv1", "subnet-priv2", "subnet-priv3"] # Private networking mode options: # "false" - Disabled (default) # "true" - Opt-in: runners can use private=true label # "always" - Default with opt-out: runners use private by default # "only" - Forced: all runners must use private subnets private_mode = "true" }Enable shared persistent storage across all runners for storing and sharing large files/artifacts:
module "runs-on" { source = "runs-on/runs-on/aws" version = "v2.11.0-r1" github_organization = "my-org" license_key = "your-license-key" email = "alerts@example.com" vpc_id = "vpc-xxxxxxxx" public_subnet_ids = ["subnet-pub1", "subnet-pub2", "subnet-pub3"] # Enables persistent shared filesystem across all runners enable_efs = true }Enable image cache across workflow jobs, including Docker build cache:
module "runs-on" { source = "runs-on/runs-on/aws" version = "v2.11.0-r1" github_organization = "my-org" license_key = "your-license-key" email = "alerts@example.com" vpc_id = "vpc-xxxxxxxx" public_subnet_ids = ["subnet-pub1", "subnet-pub2", "subnet-pub3"] # Creates private ECR for build cache enable_ecr = true }Restrict App Runner access to GitHub webhook IPs only, blocking all other internet traffic:
module "runs-on" { source = "runs-on/runs-on/aws" version = "v2.11.0-r1" github_organization = "my-org" license_key = "your-license-key" email = "alerts@example.com" vpc_id = "vpc-xxxxxxxx" public_subnet_ids = ["subnet-pub1", "subnet-pub2", "subnet-pub3"] # Enable WAF to restrict access to GitHub IPs only enable_waf = true # Optionally add your own IPs for admin access # waf_allowed_ipv4_cidrs = ["203.0.113.50/32"] }Warning
Enable WAF only AFTER completing initial GitHub App setup.
WAF blocks all traffic except GitHub webhook IPs. The setup UI at your App Runner URL requires browser access, which WAF will block.
Deployment order:
- Deploy with
enable_waf = false(default) - Access App Runner URL to configure GitHub App
- Set
enable_waf = trueand re-apply
If you need ongoing browser access (e.g., for metrics), add your IP to waf_allowed_ipv4_cidrs.
All features enabled together, with VPC endpoints for improved security and reduced data transfer costs:
# VPC with endpoints for private connectivity module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0" name = "runs-on-vpc" cidr = "10.0.0.0/16" azs = ["us-east-1a", "us-east-1b", "us-east-1c"] private_subnets = ["10.0.128.0/20", "10.0.144.0/20", "10.0.160.0/20"] public_subnets = ["10.0.0.0/20", "10.0.16.0/20", "10.0.32.0/20"] enable_nat_gateway = true single_nat_gateway = true # 'false' for High Availibility enable_dns_hostnames = true enable_dns_support = true # VPC Endpoints # Enable only if you're using private networking in RunsOn for full intra-VPC traffic to AWS APIs (avoids NAT Gateway data transfer costs). # S3 gateway endpoint is free and recommended enable_s3_endpoint = true # ECR endpoints are useful if you push/pull lots of images (enable_ecr = true) enable_ecr_api_endpoint = false # For ECR API calls enable_ecr_dkr_endpoint = false # For ECR image pulls # Interface endpoints below cost ~$7/mo each. enable_ec2_endpoint = false # For EC2 API calls enable_logs_endpoint = false # For CloudWatch Logs enable_ssm_endpoint = false # For SSM access enable_ssmmessages_endpoint = false # For SSM Session Manager } module "runs-on" { source = "runs-on/runs-on/aws" version = "v2.11.0-r1" github_organization = "my-org" license_key = "your-license-key" email = "alerts@example.com" vpc_id = module.vpc.vpc_id public_subnet_ids = module.vpc.public_subnets private_subnet_ids = module.vpc.private_subnets # Private networking (opt-in mode) private_mode = "true" # EFS shared storage enable_efs = true # ECR container registry enable_ecr = true # CloudWatch dashboard for monitoring enable_dashboard = true }| Name | Version |
|---|---|
| terraform | >= 1.5.7 |
| aws | >= 6.0 |
| http | >= 3.0 |
| time | >= 0.9 |
| Name | Version |
|---|---|
| aws | 6.28.0 |
| time | 0.13.1 |
| Name | Source | Version |
|---|---|---|
| compute | ./modules/compute | n/a |
| core | ./modules/core | n/a |
| optional | ./modules/optional | n/a |
| storage | ./modules/storage | n/a |
| Name | Type |
|---|---|
| aws_security_group.runners | resource |
| aws_vpc_security_group_egress_rule.all_ipv4 | resource |
| aws_vpc_security_group_egress_rule.all_ipv6 | resource |
| aws_vpc_security_group_ingress_rule.ssh | resource |
| time_sleep.wait_for_nat | resource |
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| Email address for alerts and notifications (requires confirmation) | string | n/a | yes | |
| github_organization | GitHub organization or username for RunsOn integration | string | n/a | yes |
| license_key | RunsOn license key obtained from runs-on.com | string | n/a | yes |
| public_subnet_ids | List of public subnet IDs for runner instances (requires at least 1) | list(string) | n/a | yes |
| vpc_id | VPC ID where RunsOn infrastructure will be deployed | string | n/a | yes |
| alert_https_endpoint | HTTPS endpoint for alert notifications (optional) | string | "" | no |
| alert_slack_webhook_url | Slack webhook URL for alert notifications (optional) | string | "" | no |
| app_alarm_daily_minutes | Daily budget in minutes for the App Runner service before triggering an alarm | number | 4000 | no |
| app_cpu | CPU units for App Runner service (256, 512, 1024, 2048, 4096) | number | 256 | no |
| app_debug | Enable debug mode for RunsOn stack (prevents auto-shutdown of failed runner instances) | bool | false | no |
| app_ecr_repository_url | Private ECR repository URL for RunsOn image (e.g., 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo:tag). When specified, App Runner will pull from this private ECR instead of public ECR. | string | "" | no |
| app_image | App Runner container image for RunsOn service | string | "public.ecr.aws/c5h5o9k1/runs-on/runs-on:v2.11.0@sha256:875bcd8a36be7be78509a4c8371cdb4bff01af06c49f4a2d2a2647e3bf44bac5" | no |
| app_memory | Memory in MB for App Runner service (512, 1024, 2048, 3072, 4096, 6144, 8192, 10240, 12288) | number | 512 | no |
| app_tag | Application version tag for RunsOn service | string | "v2.11.0" | no |
| bootstrap_tag | Bootstrap script version tag | string | "v0.1.12" | no |
| cache_expiration_days | Number of days to retain cache artifacts in S3 before expiration | number | 10 | no |
| cost_allocation_tag | Name of the tag key used for cost allocation and tracking | string | "stack" | no |
| default_admins | Comma-separated list of default admin usernames | string | "" | no |
| detailed_monitoring_enabled | Enable detailed CloudWatch monitoring for EC2 instances (increases costs) | bool | false | no |
| ebs_encryption_enabled | Enable encryption for EBS volumes on runner instances | bool | false | no |
| ebs_encryption_key_id | KMS key ID for EBS volume encryption (leave empty for AWS managed key) | string | "" | no |
| ec2_queue_size | Maximum number of EC2 instances in queue | number | 2 | no |
| enable_cost_reports | Enable automated cost reports sent to alert email | bool | true | no |
| enable_dashboard | Create a CloudWatch dashboard for monitoring RunsOn operations (number of jobs processed, rate limit status, last error messages, etc.) | bool | true | no |
| enable_ecr | Enable ECR repository for ephemeral Docker image storage | bool | false | no |
| enable_efs | Enable EFS file system for shared storage across runners | bool | false | no |
| enable_waf | Enable AWS WAF for App Runner service to restrict access to allowed IP ranges | bool | false | no |
| environment | Environment name used for resource tagging and RunsOn job filtering. RunsOn will only process jobs with an 'env' label matching this value. See https://runs-on.com/configuration/environments/ for details. | string | "production" | no |
| force_delete_ecr | Allow ECR repository to be deleted even when it contains images. Set to true for testing environments. | bool | false | no |
| force_destroy_buckets | Allow S3 buckets to be destroyed even when not empty. Set to false for production environments to prevent accidental data loss. | bool | false | no |
| github_api_strategy | Strategy for GitHub API calls (normal, conservative) | string | "normal" | no |
| github_enterprise_url | GitHub Enterprise Server URL (optional, leave empty for github.com) | string | "" | no |
| integration_step_security_api_key | API key for StepSecurity integration (optional) | string | "" | no |
| ipv6_enabled | Enable IPv6 support for runner instances | bool | false | no |
| log_retention_days | Number of days to retain CloudWatch logs for EC2 instances | number | 7 | no |
| logger_level | Logging level for RunsOn service (debug, info, warn, error) | string | "info" | no |
| otel_exporter_endpoint | OpenTelemetry exporter endpoint for observability (optional) | string | "" | no |
| otel_exporter_headers | OpenTelemetry exporter headers (optional) | string | "" | no |
| permission_boundary_arn | IAM permissions boundary ARN to attach to all IAM roles (optional) | string | "" | no |
| prevent_destroy_optional_resources | Prevent destruction of EFS and ECR resources. Set to true for production environments to protect against accidental data loss. | bool | true | no |
| private_mode | Private networking mode: 'false' (disabled), 'true' (opt-in with label), 'always' (default with opt-out), 'only' (forced, no public option) | string | "false" | no |
| private_subnet_ids | List of private subnet IDs for runner instances (required if private_mode is not 'false') | list(string) | [] | no |
| runner_config_auto_extends_from | Auto-extend runner configuration from this base config | string | ".github-private" | no |
| runner_custom_tags | Custom tags to apply to runner instances (comma-separated list) | list(string) | [] | no |
| runner_default_disk_size | Default EBS volume size in GB for runner instances | number | 40 | no |
| runner_default_volume_throughput | Default EBS volume throughput in MiB/s (gp3 volumes only) | number | 400 | no |
| runner_large_disk_size | Large EBS volume size in GB for runner instances requiring more storage | number | 80 | no |
| runner_large_volume_throughput | Large EBS volume throughput in MiB/s (gp3 volumes only) | number | 750 | no |
| runner_max_runtime | Maximum runtime in minutes for runners before forced termination | number | 720 | no |
| security_group_ids | Security group IDs for runner instances and App Runner service. If empty list provided, security groups will be created automatically. | list(string) | [] | no |
| server_password | Password for RunsOn server admin interface (optional) | string | "" | no |
| spot_circuit_breaker | Spot instance circuit breaker configuration (e.g., '2/15/30' = 2 failures in 15min, block for 30min) | string | "2/15/30" | no |
| sqs_queue_oldest_message_threshold_seconds | Threshold in seconds for oldest message in SQS queues before triggering an alarm (0 to disable) | number | 0 | no |
| ssh_allowed | Allow SSH access to runner instances | bool | true | no |
| ssh_cidr_range | CIDR range allowed for SSH access to runner instances (only applies if ssh_allowed is true) | string | "0.0.0.0/0" | no |
| stack_name | Name for the RunsOn stack (used for resource naming) | string | "runs-on" | no |
| tags | Tags to apply to all resources. Note: 'runs-on-stack-name' is added automatically for resource discovery. | map(string) | {} | no |
| waf_allowed_ipv4_cidrs | List of IPv4 CIDR blocks to allow through WAF (in addition to GitHub webhook IPs) | list(string) | [] | no |
| waf_allowed_ipv6_cidrs | List of IPv6 CIDR blocks to allow through WAF (in addition to GitHub webhook IPs) | list(string) | [] | no |
| Name | Description |
|---|---|
| apprunner_log_group_name | CloudWatch log group name for App Runner service |
| apprunner_service_arn | ARN of the RunsOn App Runner service |
| apprunner_service_status | Status of the RunsOn App Runner service |
| apprunner_service_url | URL of the RunsOn App Runner service |
| aws_account_id | AWS Account ID where RunsOn is deployed |
| aws_region | AWS region where RunsOn is deployed |
| cache_bucket_name | Name of the S3 cache bucket |
| config_bucket_name | Name of the S3 configuration bucket |
| dashboard_name | Name of the CloudWatch Dashboard (if enabled) |
| dashboard_url | URL to the CloudWatch Dashboard (if enabled) |
| dynamodb_locks_table_name | Name of the DynamoDB locks table |
| dynamodb_workflow_jobs_table_name | Name of the DynamoDB workflow jobs table |
| ec2_instance_log_group_name | CloudWatch log group name for EC2 instances |
| ec2_instance_profile_arn | ARN of the EC2 instance profile |
| ec2_instance_role_arn | ARN of the EC2 instance IAM role |
| ec2_instance_role_name | Name of the EC2 instance IAM role |
| ecr_repository_name | Name of the ECR repository (if enabled) |
| ecr_repository_url | URL of the ECR repository (if enabled) |
| efs_file_system_dns_name | DNS name of the EFS file system (if enabled) |
| efs_file_system_id | ID of the EFS file system (if enabled) |
| getting_started | Quick start guide for using this RunsOn deployment |
| launch_template_linux_default_id | ID of the Linux default launch template |
| launch_template_linux_private_id | ID of the Linux private launch template (if private networking enabled) |
| launch_template_windows_default_id | ID of the Windows default launch template |
| launch_template_windows_private_id | ID of the Windows private launch template (if private networking enabled) |
| logging_bucket_name | Name of the S3 logging bucket |
| security_group_ids | Security group IDs being used (created or provided) |
| sns_topic_arn | ARN of the SNS alerts topic |
| sqs_queue_events_url | URL of the events SQS queue |
| sqs_queue_github_url | URL of the GitHub SQS queue |
| sqs_queue_housekeeping_url | URL of the housekeeping SQS queue |
| sqs_queue_jobs_url | URL of the jobs SQS queue |
| sqs_queue_main_url | URL of the main SQS queue |
| sqs_queue_pool_url | URL of the pool SQS queue |
| sqs_queue_termination_url | URL of the termination SQS queue |
| stack_name | The stack name used for this deployment |
| waf_web_acl_arn | ARN of the WAF Web ACL (if enabled) |
| waf_web_acl_id | ID of the WAF Web ACL (if enabled) |
MIT