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

%%{init: {'theme': 'dark'}}%% flowchart LR Pi["๐Ÿ“ Raspberry Pi 5\nstep-ca (Private CA)"] MM["๐Ÿ–ฅ๏ธ Mac Mini\nGitLab Runner"] CH["rolesanywhere\ncredential-helper"] RA["โ˜๏ธ AWS\nIAM Roles Anywhere"] STS["โ˜๏ธ AWS STS\nTemp Credentials"] Pi -->|"issues X.509 cert\n(over Tailscale)"| MM MM --> CH CH -->|"presents cert"| RA RA -->|"returns temp creds"| STS STS -->|"short-lived token"| CH

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

ComponentHostRole
step-caRaspberry Pi 5Private CA โ€” issues X.509 certs
step CLIRaspberry Pi 5 + Mac MiniCA management + cert issuance
GitLab RunnerMac MiniExecutes CI/CD pipelines
rolesanywhere-credential-helperMac MiniExchanges cert for temporary AWS credentials
IAM Roles AnywhereAWSTrust anchor + credential vending
TailscaleBoth hostsNetwork 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: --dns accepts 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:

FileSource
runner.crtissued by step ca certificate
runner.keyissued 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 CreateSession calls โ€” effectively free for a single runner.

References Link to heading