Deploy RunsOn self-hosted GitHub Actions runners on AWS with Terraform/OpenTofu.
terraform {
required_version = ">= 1.6.0"
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"
# Required: GitHub and License
github_organization = "my-org"
license_key = "your-license-key"
email = "[email protected]"
# 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}
v{MAJOR}.{MINOR}.{PATCH}- Matches the RunsOn application version (e.g.,v2.11.0)
Examples:
v2.10.0- Terraform release for RunsOn v2.10.0v2.11.0- Terraform release for RunsOn v2.11.0
When upgrading, check:
- The RunsOn version changelog at runs-on.com/changelog
- The Terraform module release notes in this repository
Tip
Cost Estimates:
- RunsOn base: ~$3/mo (App Runner)
- NAT Gateway: ~$32/mo per gateway + data transfer charges (required for private networking)
- VPC Endpoints: ~$7/mo per interface endpoint + data transfer charges (S3 gateway endpoint is free)
- EFS: ~$0.30/GB-month for storage
- ECR: ~$0.10/GB-month for storage
- Runners: EC2 costs vary by instance type and usage (pay only for what you use)
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
Standard deployment with smart defaults:
module "runs-on" {
source = "runs-on/runs-on/aws"
version = "v2.11.0"
github_organization = "my-org"
license_key = "your-license-key"
email = "[email protected]"
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"
github_organization = "my-org"
license_key = "your-license-key"
email = "[email protected]"
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"
github_organization = "my-org"
license_key = "your-license-key"
email = "[email protected]"
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"
github_organization = "my-org"
license_key = "your-license-key"
email = "[email protected]"
vpc_id = "vpc-xxxxxxxx"
public_subnet_ids = ["subnet-pub1", "subnet-pub2", "subnet-pub3"]
# Creates private ECR for build cache
enable_ecr = true
}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"
github_organization = "my-org"
license_key = "your-license-key"
email = "[email protected]"
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.6.0 |
| aws | >= 6.0 |
| time | >= 0.9 |
| Name | Version |
|---|---|
| aws | 6.24.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_image | App Runner container image for RunsOn service | string |
"public.ecr.aws/c5h5o9k1/runs-on/runs-on:v2.10.0" |
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.10.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 |
| 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 |
| 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 |
MIT