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