AWS Lambda with Terraform
The same three resources as the CLI deployment — execution
role, function, Function URL — expressed as a single main.tf. Use this if
you already run Terraform; if not, the CLI version has fewer concepts to
learn for an identical result.
Warning
The CA private key is passed to the function as an environment variable,
and Terraform records resource attributes in its state: terraform.tfstate
contains the CA private key in plaintext. Treat the state file exactly
like the key itself. At this scale, local state in a private directory is
fine — there is no need to set up a remote backend just for this — but do
not commit it, and if you do use a remote backend, make sure it is private
and encrypted.
1. Build the zip
Same as the CLI deployment — Terraform deploys the artifact, it does not build it:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
go build -trimpath -ldflags="-s -w" -tags lambda.norpc \
-o bootstrap ./cmd/oidc-ssh-ca
zip lambda.zip bootstrap policy.yaml
2. main.tf
Place lambda.zip and the ca_key file next to this configuration:
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
resource "aws_iam_role" "lambda" {
name = "oidc-ssh-ca-lambda"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
# CloudWatch Logs is the only permission the function needs.
resource "aws_iam_role_policy_attachment" "logs" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "ca" {
function_name = "oidc-ssh-ca"
role = aws_iam_role.lambda.arn
runtime = "provided.al2023"
architectures = ["arm64"]
handler = "bootstrap"
filename = "${path.module}/lambda.zip"
source_code_hash = filebase64sha256("${path.module}/lambda.zip")
timeout = 10
memory_size = 128
# Bounds the cost of an unauthenticated endpoint.
# Emergency stop: set to 0 and apply.
reserved_concurrent_executions = 5
environment {
variables = {
# Lambda environment variables are encrypted at rest.
OIDC_SSH_CA_KEY = file("${path.module}/ca_key")
}
}
}
# Public invocation is intentional: /sign authenticates callers by
# verifying their OIDC token, the same model as running the server
# on a public host.
resource "aws_lambda_function_url" "ca" {
function_name = aws_lambda_function.ca.function_name
authorization_type = "NONE"
}
resource "aws_lambda_permission" "public_url" {
statement_id = "allow-public-function-url"
action = "lambda:InvokeFunctionUrl"
function_name = aws_lambda_function.ca.function_name
principal = "*"
function_url_auth_type = "NONE"
}
output "function_url" {
description = "OIDC_SSH_CA_URL for the GitHub Actions workflow"
value = aws_lambda_function_url.ca.function_url
}
3. Apply
terraform init
terraform apply
The function_url output is the OIDC_SSH_CA_URL for the GitHub Actions
workflow.
Operations
Update the binary or the policy — rebuild the zip and apply;
source_code_hash makes Terraform pick up the change:
zip lambda.zip bootstrap policy.yaml
terraform apply
Emergency stop — for speed, bypass Terraform and use the CLI directly, then reconcile:
aws lambda put-function-concurrency \
--function-name oidc-ssh-ca \
--reserved-concurrent-executions 0
Afterwards set reserved_concurrent_executions = 0 in main.tf so the next
terraform apply does not silently re-enable issuance.
Tear down:
terraform destroy
Audit logs go to CloudWatch Logs (/aws/lambda/oidc-ssh-ca), the same
as the CLI deployment.