Skip to content

field_note · jul 19

Shipping to one box, safely: Kamal + Terraform + GitHub OIDC with no long-lived AWS keys

The full deploy pipeline for a single-box production service — Terraform provisions the host, network, IAM and edge; GitHub Actions federates into AWS via OIDC (no stored keys); CI gates on security scans; Kamal ships containers over SSH-through-SSM with zero open ports.

In a companion post I covered why this service runs on one box behind a Cloudflare Tunnel with no Elastic IP. This one is about how it ships — the end-to-end pipeline that provisions the box, builds the images, and deploys them without a single long-lived cloud credential or open SSH port.

Three tools, three jobs:

  • Terraform owns the infrastructure — host, network, IAM, edge tunnel.
  • GitHub Actions runs CI and orchestrates the deploy.
  • Kamal ships the containers onto the box.

Terraform: the box and its guardrails, as code

Terraform provisions everything the deploy lands on, so the pipeline never has to click through a console or hold static secrets:

  • An instance with an SSM instance profile attached. This is the quiet hero: it lets the box register with the cloud provider's session manager, which is what makes SSH-over-SSM work later — no SSH key baked into an AMI, no bastion.
  • A security group that blocks all inbound. No port 22, no port 443. (Ingress is the Cloudflare Tunnel from the other post.)
  • A GitHub OIDC provider plus a deploy role the CI workflow can assume — and that role is scoped to essentially one action: start an SSM session.
  • The edge tunnel resources, so the only way in is defined in code too. (The token wiring has a v5 schema gotcha — see cloudflared tunnel token in Terraform v5.)

The point: the box, its firewall, its identity, and its front door all live in version control. A rebuild is terraform apply, not tribal knowledge.

GitHub OIDC: deploy into AWS with zero stored keys

This is the part most small deployments get wrong — they drop an AWS_ACCESS_KEY_ID into repo secrets and hope it never leaks. There's a better way: OIDC federation. GitHub mints a short-lived identity token for the workflow run; AWS trusts that token and hands back temporary STS credentials. No long-lived key exists to leak.

The workflow just declares it wants a token and assumes the role:

terminal
permissions:
  id-token: write      # mint the OIDC token
  packages: write      # push the image to the container registry

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
      aws-region: <region>

The role's trust policy pins it to this repo (via the OIDC subject claim), and its permissions are scoped to ssm:StartSession — nothing else. Even if the workflow were fully compromised, the blast radius is "can open an SSM session," not "owns the account."

CI: gate before you ship

On every push and PR, CI runs cheap, fast guards before anything is allowed near production:

  • Static security scan of the app code,
  • Dependency audit for known CVEs in third-party packages,
  • Lint for consistent style.

None of these need cloud access — they run on the runner and fail the build early. Deployment is a separate, deliberate job, not something that rides every commit automatically.

Kamal: the actual deploy, over SSH-through-SSM

The deploy job builds the container image, pushes it to the registry, then hands off to Kamal:

terminal
build image ─▶ push to registry ─▶ assume AWS role (OIDC)
     └─▶ open SSM session ─▶ Kamal SSHes to box by INSTANCE ID ─▶ deploy

The trick that keeps port 22 closed: Kamal reaches the box by its instance ID, tunneled through the session manager, using the temporary credentials from the OIDC step. SSH still happens — but over an SSM channel the deploy role is allowed to open, not an inbound port anyone on the internet can reach.

Once connected, Kamal does what it does well:

  • boots the new container(s) alongside the old,
  • waits on a health-check path before cutting traffic over via its proxy,
  • brings up the supporting accessories (database, background services, the tunnel connector) as their own containers with persistent volumes,
  • keeps a boot limit so the swap never runs more copies than the box's RAM can hold.

First-ever deploy is a one-time setup (bootstrap host + accessories); every deploy after is the same idempotent deploy.

Why this shape is worth the setup

  • No long-lived cloud keys anywhere — OIDC issues short-lived credentials per run; there's no secret to rotate or leak.
  • No open SSH / no bastion — deploy access rides the session manager, gated by a narrowly-scoped IAM role.
  • Infrastructure and delivery both in code — Terraform for the box and its guardrails, Kamal for the app; a rebuild or a redeploy is a command, not a runbook.
  • Security gates run before deploy, not after an incident.

None of this requires a platform team. It's a handful of Terraform files, one CI workflow, one deploy workflow, and a Kamal config — small enough for one person to own, strict enough that a leaked repo token can't take the account with it.

The takeaway: short-lived federated credentials and infrastructure-as-code let a one-person, one-box deploy have the security posture people usually associate with far heavier setups — no stored keys, no open ports, no manual steps.

Working through something like this? I help teams ship AI and cloud systems that hold up, and cost what they should.