Keyless AWS Credentials for a Self-Hosted GitLab Runner Using IAM Roles Anywhere and step-ca
Long-lived AWS access keys are a liability. If your self-hosted GitLab runner stores an AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in its environment, a compromised runner means a compromised key โ and rotating it means updating secrets everywhere that references it.
AWS IAM Roles Anywhere solves this for non-AWS workloads. Instead of a static key, your runner presents an X.509 certificate to AWS and receives short-lived STS credentials in return. No keys to rotate, no secrets to store, and every session is cryptographically tied to a certificate your own CA issued. If the concept is new to you, this earlier post covers the fundamentals โ this post assumes that foundation and goes straight into a production homelab setup.
This post walks through the full setup: a self-hosted step-ca Private CA on a Raspberry Pi 5, a GitLab runner on a Mac Mini, and the AWS-side plumbing to wire them together.
Architecture Link to heading
The Raspberry Pi runs step-ca as a Private Certificate Authority. The Mac Mini’s GitLab runner uses the rolesanywhere-credential-helper binary to exchange a leaf certificate (issued by the Pi’s CA) for temporary AWS credentials. Tailscale keeps the CA off the public internet โ the Mac Mini reaches the Pi over a private overlay network.
Components Link to heading
| Component | Host | Role |
|---|---|---|
| step-ca | Raspberry Pi 5 | Private CA โ issues X.509 certs |
| step CLI | Raspberry Pi 5 + Mac Mini | CA management + cert issuance |
| GitLab Runner | Mac Mini | Executes CI/CD pipelines |
| rolesanywhere-credential-helper | Mac Mini | Exchanges cert for temporary AWS credentials |
| IAM Roles Anywhere | AWS | Trust anchor + credential vending |
| Tailscale | Both hosts | Network boundary โ CA is never publicly exposed |
Part 1: Raspberry Pi โ Set Up the Private CA Link to heading
Install step CLI and step-ca Link to heading
wget https://dl.smallstep.com/cli/docs-ca-install/latest/step-cli_arm64.deb
sudo dpkg -i step-cli_arm64.deb
wget https://dl.smallstep.com/certificates/docs-ca-install/latest/step-ca_arm64.deb
sudo dpkg -i step-ca_arm64.deb
Get the Tailscale IP Link to heading
The CA will bind to this address. Grab it before initializing:
tailscale ip -4
Initialize the CA Link to heading
step ca init \
--name homelab-ca \
--dns <tailscale-ip> \
--address :9000 \
--provisioner admin@homelab
Note:
--dnsaccepts an IP address here. step-ca will embed it as an IP SAN (not a DNS SAN) in the CA certificate โ that’s fine, but it means the Mac Mini must reach the CA at exactly this address, so a stable Tailscale IP matters.
You’ll be prompted to set a password that encrypts the CA private key. Store it somewhere safe โ you’ll need it to issue certificates and run the service.
Create a dedicated system user Link to heading
sudo useradd --system --home /etc/step-ca --shell /bin/false step
sudo mkdir -p /etc/step-ca
sudo cp -r ~/.step/* /etc/step-ca/
sudo chown -R step:step /etc/step-ca
Store the CA password Link to heading
echo "your-password" | sudo tee /etc/step-ca/password.txt
sudo chmod 600 /etc/step-ca/password.txt
sudo chown step:step /etc/step-ca/password.txt
Create a systemd service Link to heading
/etc/systemd/system/step-ca.service:
[Unit]
Description=step-ca
After=network.target
[Service]
User=step
ExecStart=/usr/bin/step-ca /etc/step-ca/config/ca.json --password-file /etc/step-ca/password.txt
Restart=on-failure
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now step-ca
If the service fails to start, check journalctl -u step-ca. A common issue is that ca.json still references the default home directory path for the database โ grep for it and update "dataSource" to /etc/step-ca/db.
Note the root certificate fingerprint Link to heading
step certificate fingerprint /etc/step-ca/certs/root_ca.crt
Save this hash โ you’ll need it to bootstrap trust on the Mac Mini.
Part 2: Mac Mini โ Configure the Runner Link to heading
Install step CLI and the credential helper Link to heading
brew install step
Download the aws_signing_helper binary from the AWS credential helper releases page, then make it executable and move it onto your PATH:
chmod +x aws_signing_helper
sudo mv aws_signing_helper /usr/local/bin/
Bootstrap trust against the Pi CA Link to heading
step ca bootstrap \
--ca-url https://<tailscale-ip>:9000 \
--fingerprint <fingerprint-from-pi>
This pulls the root certificate from the Pi and installs it as a trusted CA. Note that bootstrap only pulls the root cert โ the intermediate cert must be copied separately for the trust anchor bundle (covered in the AWS setup below).
Issue a certificate for the runner Link to heading
step ca certificate gitlab-runner \
/etc/gitlab-runner/certs/runner.crt \
/etc/gitlab-runner/certs/runner.key
You’ll be prompted for the provisioner password set during step ca init. The resulting certificate is signed by the intermediate CA, not the root directly โ which is why AWS needs both in its trust anchor.
Store the files in a stable directory โ /etc/gitlab-runner/certs/ is a natural choice since the runner already owns that path. This is the directory you’ll reference in config.toml for the Docker volume mount in Part 3.
At this point the two runtime files the credential helper needs are on the Mac Mini:
| File | Source |
|---|---|
runner.crt | issued by step ca certificate |
runner.key | issued by step ca certificate |
The CA certificates (root_ca.crt, intermediate_ca.crt) are also needed โ but only for building the Terraform trust anchor bundle. The fetch-certs.sh script in Part 3 handles copying and concatenating them into the right place.
Part 3: AWS โ IAM Roles Anywhere with Terraform Link to heading
All three AWS resources (trust anchor, IAM role, Roles Anywhere profile) are managed as Terraform. Set it up in a directory โ e.g. terraform/iam_roles_anywhere/ โ alongside a certs/ subdirectory and a helper script.
Fetch the CA certificates Link to heading
The Terraform module reads the CA bundle from a local file. A small script fetches both certs from the Pi and concatenates them:
scripts/fetch-certs.sh:
#!/usr/bin/env bash
set -euo pipefail
REMOTE_HOST="<your-username>@<tailscale-ip>"
REMOTE_CERT_DIR="/home/<your-username>/.step/certs"
LOCAL_CERT_DIR="$(cd "$(dirname "$0")/.." && pwd)/certs"
mkdir -p "$LOCAL_CERT_DIR"
echo "==> Fetching certs from $REMOTE_HOST..."
scp "$REMOTE_HOST:$REMOTE_CERT_DIR/root_ca.crt" "$LOCAL_CERT_DIR/root.crt"
scp "$REMOTE_HOST:$REMOTE_CERT_DIR/intermediate_ca.crt" "$LOCAL_CERT_DIR/intermediate.crt"
echo "==> Building bundle..."
cat "$LOCAL_CERT_DIR/root.crt" "$LOCAL_CERT_DIR/intermediate.crt" > "$LOCAL_CERT_DIR/bundle.pem"
echo "Done. Bundle written to $LOCAL_CERT_DIR/bundle.pem"
chmod +x scripts/fetch-certs.sh
./scripts/fetch-certs.sh
Terraform files Link to heading
terraform.tf โ backend and provider:
terraform {
required_version = ">= 1.10" # use_lockfile requires 1.10+ (native S3 locking, no DynamoDB needed)
backend "s3" {
bucket = "<your-tf-state-bucket>"
key = "iam_roles_anywhere.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.23.0"
}
}
}
provider "aws" {}
variables.tf:
variable "trust_anchor_name" {
type = string
description = "Name for the IAM Roles Anywhere trust anchor."
}
variable "profile_name" {
type = string
description = "Name for the IAM Roles Anywhere profile."
}
variable "role_name" {
type = string
description = "Name for the IAM role workloads will assume."
}
variable "ca_bundle_path" {
type = string
description = "Path to a PEM file containing the CA certificate(s) for the trust anchor. Concatenate root + intermediate into one file if needed."
}
variable "role_policy_arns" {
type = list(string)
description = "List of IAM managed policy ARNs to attach to the role."
default = []
}
variable "allowed_cn" {
type = string
description = "Certificate CN that is allowed to assume the role via Roles Anywhere."
}
variable "session_duration_seconds" {
type = number
description = "Max session duration (seconds) for credentials issued by the profile."
default = 3600
}
main.tf:
resource "aws_rolesanywhere_trust_anchor" "this" {
name = var.trust_anchor_name
enabled = true
source {
source_type = "CERTIFICATE_BUNDLE"
source_data {
x509_certificate_data = file(var.ca_bundle_path)
}
}
}
resource "aws_iam_role" "this" {
name = var.role_name
max_session_duration = var.session_duration_seconds
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = { Service = "rolesanywhere.amazonaws.com" }
Action = [
"sts:AssumeRole",
"sts:TagSession",
"sts:SetSourceIdentity",
]
Condition = {
ArnEquals = {
"aws:SourceArn" = aws_rolesanywhere_trust_anchor.this.arn
}
StringEquals = {
"aws:PrincipalTag/x509Subject/CN" = var.allowed_cn
}
}
},
]
})
}
resource "aws_iam_role_policy_attachment" "this" {
for_each = toset(var.role_policy_arns)
role = aws_iam_role.this.name
policy_arn = each.value
}
resource "aws_rolesanywhere_profile" "this" {
name = var.profile_name
enabled = true
role_arns = [aws_iam_role.this.arn]
duration_seconds = var.session_duration_seconds
require_instance_properties = false
}
Two things worth noting in the trust policy: the aws:SourceArn condition prevents a cert from a different trust anchor from assuming this role; the aws:PrincipalTag/x509Subject/CN condition locks it to certificates with exactly that Common Name.
outputs.tf:
output "trust_anchor_arn" {
value = aws_rolesanywhere_trust_anchor.this.arn
}
output "profile_arn" {
value = aws_rolesanywhere_profile.this.arn
}
output "role_arn" {
value = aws_iam_role.this.arn
}
terraform.tfvars:
trust_anchor_name = "gitlab-runner-ca"
profile_name = "homelab-profile"
role_name = "<your-role-name>"
ca_bundle_path = "./certs/bundle.pem"
role_policy_arns = [
# Attach only the policies your pipelines actually need โ avoid broad managed
# policies like AdministratorAccess. This role is assumed by every job that
# extends .terraform_aws, so its permissions are the blast radius of a
# compromised runner cert.
# "arn:aws:iam::aws:policy/ReadOnlyAccess",
]
allowed_cn = "gitlab-runner"
session_duration_seconds = 3600
Apply Link to heading
terraform init
terraform apply
Verify locally Link to heading
Before wiring credentials into CI, confirm everything works from the Mac Mini’s shell. This profile is only used for this one-time check โ the CI pipeline provisions credentials differently via before_script, as covered in the next section.
Pull the ARNs directly from Terraform output:
terraform output trust_anchor_arn
terraform output profile_arn
terraform output role_arn
Add a temporary profile entry to ~/.aws/config. The credential_process value must be a single line โ the backslashes below are for readability only:
[profile gitlab-runner]
credential_process = aws_signing_helper credential-process \
--certificate /path/to/runner.crt \
--private-key /path/to/runner.key \
--trust-anchor-arn <trust_anchor_arn> \
--profile-arn <profile_arn> \
--role-arn <role_arn>
Run:
aws --profile gitlab-runner sts get-caller-identity
Expected response:
{
"UserId": "AROA...:session-name",
"Account": "<account-id>",
"Arn": "arn:aws:sts::<account-id>:assumed-role/<your-role-name>/..."
}
Use the credentials in .gitlab-ci.yml
Link to heading
On a shell executor you could configure credential_process in ~/.aws/config and the AWS SDK would call the signing helper automatically โ re-invoking it before credentials expire, with no manual refresh needed.
However, the Docker executor breaks that model: each job gets a fresh container with no ~/.aws/config and no aws_signing_helper binary, so the SDK has nothing to call.
The before_script replicates that behaviour manually: download the helper, call it once, and export the resulting keys as environment variables. This is fine for CI because individual jobs complete well within the 3600-second session duration โ there’s no mid-job expiry to worry about.
The cert files are bind-mounted from the Mac Mini into every container via the runner’s config.toml:
[[runners.docker]]
volumes = ["/etc/gitlab-runner/certs:/certs:ro"]
We have an example of my .gitlab-ci.yml for my AWS project using Terraform for IaC. In .gitlab-ci.yml, store the three Terraform output ARNs as CI/CD variables (TRUST_ANCHOR_ARN, PROFILE_ARN, ROLE_ARN) and use a shared job template. The template extends a base .terraform job that handles Terraform installation and terraform init โ .terraform_aws layers AWS credential provisioning on top of it:
.terraform_aws:
extends: .terraform
tags: [mac-docker]
before_script:
- terraform --version
- apk add --no-cache jq curl gcompat > /dev/null # gcompat provides glibc compatibility on Alpine for the signing helper binary
- |
case $(uname -m) in
x86_64) ARCH=X86_64 ;;
aarch64) ARCH=Aarch64 ;;
esac
curl -fLo /tmp/aws_signing_helper \
"https://rolesanywhere.amazonaws.com/releases/1.8.1/${ARCH}/Linux/Amzn2023/aws_signing_helper"
chmod +x /tmp/aws_signing_helper
- |
CREDS=$(/tmp/aws_signing_helper credential-process \
--certificate /certs/runner.crt \
--private-key /certs/runner.key \
--trust-anchor-arn ${TRUST_ANCHOR_ARN} \
--profile-arn ${PROFILE_ARN} \
--role-arn ${ROLE_ARN})
export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r '.AccessKeyId')
export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r '.SecretAccessKey')
export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r '.SessionToken')
Any job that extends .terraform_aws starts with valid, short-lived AWS credentials already in its environment. The signing helper is re-downloaded each run because Alpine containers don’t persist state โ if your image already includes it, skip that step.
This is a method that I found works for the docker executor as a gitlab-runner. If you’re using shell, then we can set the profile as gitlab-runner and the terraform commands should utilize that profile when initializing and applying. If anyone has a much cleaner method of injecting the AWS Credentials into a docker container, I am interested in learning!
Certificate Renewal Link to heading
Certificates issued by step-ca have a default lifetime of 24 hours. Request a longer lifetime when issuing โ 7 days gives headroom if the Pi is temporarily unreachable:
step ca certificate gitlab-runner runner.crt runner.key --not-after 168h
Automate renewal with a launchd job on the Mac Mini. Create /Library/LaunchDaemons/com.homelab.renew-runner-cert.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.homelab.renew-runner-cert</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/step</string>
<string>ca</string>
<string>renew</string>
<string>--force</string>
<string>/path/to/runner.crt</string>
<string>/path/to/runner.key</string>
</array>
<key>StartInterval</key>
<integer>43200</integer>
<key>StandardErrorPath</key>
<string>/var/log/renew-runner-cert.log</string>
</dict>
</plist>
sudo launchctl load /Library/LaunchDaemons/com.homelab.renew-runner-cert.plist
This runs every 12 hours. step ca renew is a no-op if the cert is not yet within its renewal window, so running it frequently is safe. If the Pi is unreachable, the existing cert continues to work until it expires โ the next successful renewal resets the clock.
Security Notes Link to heading
- step-ca is bound to the Tailscale interface only โ the Pi’s CA endpoint is never exposed to the public internet.
- The CA private key is encrypted at rest.
- The runner only receives short-lived leaf certificates โ it never touches the CA key.
- The IAM role trust policy is scoped to a specific
CNโ no other cert issued by the same CA can assume the role. - There are no long-lived AWS credentials anywhere in this setup.
- Cost: IAM Roles Anywhere charges approximately $0.10 per 100
CreateSessioncalls โ effectively free for a single runner.