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:
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:
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.