gherkin-ai v3.2.2

Deploying a generated service

Every generated backend can be deployed as one container image to AWS, GCP, Azure or any Kubernetes cluster. This page covers:

Production image

ghk generate writes a Dockerfile and a .dockerignore next to the backend. Each image:

docker build -t orders-svc .
docker run -p 8080:8080 -e DATABASE_URL=... -e AMQP_URL=... -e JWT_SECRET=... orders-svc
Stack Runtime image Port Liveness Readiness
NestJS node:24-bookworm-slim 3000 /health /health/ready (PostgreSQL)
Express node:24-bookworm-slim 3000 /health /health
Spring Boot 4 (Java, Kotlin) eclipse-temurin:17-jre 8080 /health /health/ready (PostgreSQL)
Quarkus eclipse-temurin:21-jre 8080 /q/health/live /q/health/ready (datasource)
ASP.NET Core mcr.microsoft.com/dotnet/aspnet:8.0 8080 /health /health
FastAPI python:3.12-slim (uvicorn) 8000 /health /health
Django python:3.12-slim (gunicorn) 8000 /health /health
Go (chi) gcr.io/distroless/static-debian12:nonroot 8080 /health /health
Rust (Axum) debian:bookworm-slim 3000 /health /health
Rails ruby:3.3-slim (Puma) 3000 /health /health
Phoenix elixir:1.17 (mix phx.server, prod) 4000 /health /health
Laravel dunglas/frankenphp:1-php8.3-alpine 8000 /up /up

Where readiness equals liveness, the stack does not yet check its dependencies in the probe.

Runtime contract

Variable Required Purpose
DATABASE_URL yes postgres://user:password@host:5432/db, optionally with ?sslmode=require (or verify-ca / verify-full with sslrootcert); every runtime honours it. Quarkus maps it to its Agroal pool
AMQP_URL for publishing (RabbitMQ) amqp://user:password@host:5672, or amqps://…:5671 for TLS (Amazon MQ, CloudAMQP). Without a configured broker, events stay in the outbox
GHK_BROKER no rabbitmq, pubsub, servicebus or kafka (see the broker table below for which backends support each). Default: the variables that are set, then stack.messaging
GHK_DECLARE_TOPOLOGY no false skips creating the exchanges, topics and subscriptions at start-up, for when infrastructure as code owns them and the service identity can only publish and consume (the generated Terraform sets it for Pub/Sub and Service Bus)
PUBSUB_PROJECT_ID (or GOOGLE_CLOUD_PROJECT) Pub/Sub GCP project of the topics and subscriptions. The token comes from the workload identity (metadata server) or GOOGLE_OAUTH_ACCESS_TOKEN
PUBSUB_EMULATOR_HOST no host:8085 of the Pub/Sub emulator (local development and tests)
SERVICEBUS_NAMESPACE Service Bus <name>.servicebus.windows.net, authenticated with Microsoft Entra ID (managed identity; AZURE_CLIENT_ID selects a user-assigned identity)
SERVICEBUS_CONNECTION_STRING Service Bus (alternative) Shared-access-key connection string. Also used for the emulator (UseDevelopmentEmulator=true)
KAFKA_BOOTSTRAP_SERVERS Kafka host:9092[,host:9092…] of the cluster
KAFKA_SECURITY_PROTOCOL no SASL_SSL for managed clusters (MSK, Confluent Cloud, Event Hubs); plaintext when unset
KAFKA_SASL_MECHANISM, KAFKA_SASL_USERNAME, KAFKA_SASL_PASSWORD with SASL PLAIN, SCRAM-SHA-256 or SCRAM-SHA-512, and the credentials
JWT_SECRET yes HS256 secret of bearer tokens. The tenant comes from the token claim only
OTEL_EXPORTER_OTLP_ENDPOINT no OpenTelemetry collector (OTLP/HTTP, e.g. http://localhost:4318). Set: every backend exports its traces there. Unset: nothing is exported and nothing connects
OTEL_SERVICE_NAME no service.name of the exported traces (default: the project name)
GHK_OUTBOX_RELAY no false turns off the in-process relay (Spring and Quarkus), e.g. when a separate relay runs
SECRET_KEY_BASE Rails, Phoenix Framework secret
APP_KEY Laravel base64:<32 bytes>
DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS Django Debug is off in the image; restrict the allowed hosts per environment

Keep secrets in the platform's secret manager (AWS Secrets Manager, GCP Secret Manager or Azure Key Vault) and inject them as environment variables. The generated Terraform does this for you. The agent firewall requires human approval for changes to Dockerfile and deploy/**.

Event broker per cloud

Cloud Broker Adapter Notes
AWS Amazon MQ for RabbitMQ RabbitMQ (all backends) Managed RabbitMQ: AMQP_URL is the broker's amqps:// endpoint. No code change
GCP Pub/Sub pubsub (all backends) Workload identity token from the metadata server
Azure Service Bus (Standard or Premium; topics need Standard+) servicebus (JVM, TypeScript, .NET, Python and Go backends, opt-in) Generated only with stack.messaging: "servicebus", because it adds the Azure SDK. Uses Entra ID through SERVICEBUS_NAMESPACE
Azure Self-managed RabbitMQ or CloudAMQP RabbitMQ Rust, Ruby (Rails), Elixir (Phoenix) and PHP (Laravel): there is no official Service Bus SDK for them. Run RabbitMQ on AKS (operator) or use CloudAMQP on Azure, and set AMQP_URL
Any Self-managed RabbitMQ (or CloudAMQP) RabbitMQ Kubernetes operator or a hosted service
Any An existing Kafka cluster: Amazon MSK, Managed Service for Apache Kafka, the Event Hubs Kafka endpoint, Confluent Cloud kafka (JVM, TypeScript, .NET, Python and Go backends, opt-in) Generated only with stack.messaging: "kafka". KAFKA_BOOTSTRAP_SERVERS, plus KAFKA_SECURITY_PROTOCOL=SASL_SSL, KAFKA_SASL_MECHANISM (PLAIN, or SCRAM-SHA-512 for MSK) and the SASL user and password. Event Hubs: user $ConnectionString, password the connection string. The service creates its topics

The adapter is chosen when the service starts: GHK_BROKER if set, otherwise the broker whose variables are present, otherwise stack.messaging. For every broker, the outbox relay marks a row as published only after the broker has accepted the message. A message that fails in the consumer goes to the dead-letter destination (<service>.dlx → <service>.dlq) and is not redelivered.

Deployment targets (deploy)

{
  "deploy": {
    "cloud": "gcp",
    "compute": "containers",
    "environments": ["dev", "staging", "prod"],
    "region": "us-central1"
  }
}

ghk generate writes deploy/README.md with the exact commands. Only backends with a production image get deployment targets.

AWS Google Cloud Azure
Containers ECS Fargate behind an ALB (HTTPS with an ACM certificate), CPU autoscaling, deployment circuit breaker Cloud Run v2 with Direct VPC egress, startup and liveness probes Container Apps with a user-assigned identity, liveness and readiness probes
Database RDS for PostgreSQL (encrypted; Multi-AZ, deletion protection and 14-day backups in prod) Cloud SQL for PostgreSQL, private IP (regional and point-in-time recovery in prod) PostgreSQL Flexible Server in a delegated subnet (zone-redundant HA in prod)
Broker Amazon MQ for RabbitMQ (AMQPS) Pub/Sub topics and subscriptions; the service account only publishes and consumes Service Bus namespace without shared keys (Entra ID data roles), or an external RabbitMQ (amqp_url input)
Broker with stack.messaging: "kafka" An existing cluster: kafka_bootstrap_servers, kafka_sasl_username and kafka_sasl_password inputs, stored in the secret manager. No Amazon MQ, Pub/Sub or Service Bus same same
Secrets Secrets Manager → task secrets Secret Manager → Cloud Run secret env Key Vault (RBAC) → Container App secrets
Logs CloudWatch Logs Cloud Logging Log Analytics
Network (input) VPC, public and private subnets VPC network and subnetwork; optional private services access Container Apps subnet, PostgreSQL delegated subnet and private DNS zone
State backend S3 (use_lockfile) GCS Azure Storage (Entra ID auth)

One virtual machine (compute: "vm")

The database, broker and secret store are the same as for containers (the same Terraform files). Only the compute changes:

AWS Google Cloud Azure
Machine EC2, Amazon Linux 2023 (instance_type, default t3.small), Elastic IP Compute Engine, Debian 12 (machine_type, default e2-small), static IP Linux VM, Ubuntu 24.04 (vm_size, default Standard_B2s), public IP
Identity Instance profile: read the service's secrets, pull from ECR The service account (also Artifact Registry reader) The user-assigned identity (Key Vault Secrets User, AcrPull)
Access SSM Session Manager (no key pair, no port 22) OS Login SSH key required by Azure; port 22 stays closed (Bastion or Run Command)
Inputs subnet_id (public) and the same vpc_id / private_subnet_ids the same network / subnetwork vm_subnet_id, admin_ssh_public_key

At boot, cloud-init installs Docker, reads each secret from the secret store with the machine's identity into /opt/app/app.env (mode 600; the values are never in the user data), and runs the image behind Caddy: with domain set, Caddy obtains a Let's Encrypt certificate for it (point the DNS record at the public_ip output); without it, the service answers plain HTTP on port 80. Stacks with a separate relay process also run it there. Only ports 80 and 443 are open, to allowed_cidrs.

Shared base stack for a monorepo (deploy.base, AWS)

By default every service owns its database instance and broker: nine services, nine RDS instances and nine brokers. With "base": { "dir": "../../infrastructure/base" } in deploy (the same directory in each service), ghk generate writes a base module at <dir>/aws for every service that points at it, and changes the service module:

Base module (<dir>/aws) Service module
One RDS for PostgreSQL instance; one role and one database per service, created by the idempotent bootstrap_sql output (run it with psql from inside the VPC, as its README shows) No database.tf or broker.tf; DATABASE_URL and AMQP_URL come from the base's remote state (base_state_bucket, base_state_key)
One Amazon MQ broker when a service uses RabbitMQ (each service keeps its own exchange and queues) Its tasks or machine join the base's clients security group
A clients security group: the only way into the database and the broker Compute, secrets, IAM and outputs as before

Apply the base first, run its bootstrap SQL, then apply the services. Each service connects with its own role to its own database. Google Cloud and Azure report a configuration error for deploy.base in this version.

A new image changes the boot script, so each deployment replaces the machine: a few minutes without service. Use containers when that matters. There is no collector sidecar: set OTEL_EXPORTER_OTLP_ENDPOINT in extra_environment to export traces.

Telemetry

The runtime kernel traces every command, outbox publish and inbox delivery with OpenTelemetry. W3C traceparent flows from the HTTP request through the outbox to the consumer; IT7 checks this in every stack.

Export. When OTEL_EXPORTER_OTLP_ENDPOINT is set, the service exports its traces over OTLP/HTTP, batched, with service.name from OTEL_SERVICE_NAME. Each stack uses its official SDK:

Stack Where the export is set up
NestJS, Express src/runtime/otel.ts (NodeTracerProvider), first thing in main.ts / server.ts
Spring Boot, Quarkus infrastructure/telemetry/OpenTelemetryConfig.java (global OpenTelemetrySdk)
ASP.NET Core AddServiceTelemetry in Program.cs (the runtime's ActivitySource, ASP.NET Core and HttpClient instrumentation)
FastAPI, Django app/runtime/otel.py, from the FastAPI lifespan / config/wsgi.py / manage.py outbox_relay
Go runtime.StartTelemetry in main
Rust runtime::start_telemetry in main (flushed after a graceful SIGTERM shutdown)
Rails config/initializers/opentelemetry.rb
Phoenix config/config.exs (opentelemetry_exporter)
Laravel RuntimeServiceProvider::boot

Collector per cloud. The generated Terraform runs an OpenTelemetry collector as a sidecar and points the service at http://localhost:4318. Set enable_tracing = false to turn it off.

Cloud Collector Destination Permission
AWS AWS Distro for OpenTelemetry (ecs-default-config.yaml) X-Ray (traces), CloudWatch (metrics) AWSXRayDaemonWriteAccess, CloudWatchAgentServerPolicy on the task role
Google Cloud Collector contrib, googlecloud exporter Cloud Trace roles/cloudtrace.agent on the service account
Azure Collector contrib, azuremonitor exporter Application Insights (on the Log Analytics workspace) Connection string as a Container App secret
Kubernetes Your cluster's collector (agent or gateway) Any Set OTEL_EXPORTER_OTLP_ENDPOINT in values-<env>.yaml

CI/CD (ci)

{ "ci": { "provider": "github" } }

The pipeline is written at the repository root, and only when ci.provider is set. With deploy and no ci, the provider defaults to GitHub Actions. The gherkin-ai version is pinned to the one that generated it.

GitHub Actions GitLab CI Azure Pipelines
Specification gates lint, validate (deployment readiness included), risk --fail-on CRITICAL on pull requests, converge report same same
Backend build, tests and the runtime integration suite in the stack's toolchain image, with PostgreSQL and RabbitMQ services same same (the toolchain image runs with docker run, so Alpine images work)
Image build, Trivy scan of dependencies and image (HIGH/CRITICAL), CycloneDX SBOM build, push to the project registry on the default branch, Trivy scan and SBOM build, Trivy scan and SBOM
Deployment one job per environment, in order, gated by GitHub environment protection rules:
1. OIDC to the cloud (no static keys);
2. push the image to ECR, Artifact Registry or ACR;
3. terraform plan and apply, or helm upgrade --atomic;
4. smoke test of the readiness probe
run deploy/ from a protected job with your OIDC federation run deploy/ from a deployment job with your workload-identity service connection

The GitHub workflow header lists the repository variables each environment needs. For example, AWS needs AWS_ROLE_ARN, AWS_REGION and ECR_REPOSITORY; GCP needs GCP_WORKLOAD_IDENTITY_PROVIDER and the other GCP variables.

Context for AI agents

ghk generate and ghk implement write .ghe/deployment-context.md and .json. They contain the runtime contract (environment variables, port and probes), the deployment target and its secrets, the rules every change must follow, and a production checklist.

The rules:

The runtime kernel's tables are created by the service itself when it starts (every replica, serialized by an advisory lock), so the first deployment needs no migration step. With GHK_MIGRATE=false the service skips it: use that when the service's database user has no DDL rights and a migration job creates the tables (RUNTIME-KERNEL.md).

The implement prompt (compact with -C) and the domain and backend agent prompts include a Deployment Readiness section.

ghk validate checks deployment readiness:

The agent firewall requires human approval for Dockerfile, deploy/**, *.tf, *.tfvars, *.tfbackend and Helm charts.

Verification

For each backend, node scripts/golden-build.js <stack> takes these steps:

  1. Builds the production image.
  2. Starts it next to PostgreSQL, RabbitMQ and an OpenTelemetry collector.
  3. Waits until both probes answer 200.
  4. Sends one command with a JWT, expects 202, and requires the collector to receive the service's spans.
  5. Removes everything.

Pass --no-image to skip this step.

The deployment code is verified without a cloud account:

Golden Checks
terraform-aws Rails (separate relay): terraform init -backend=false, validate, fmt -check
terraform-gcp Laravel (Cloud Run worker pool, APP_KEY), same checks
terraform-azure NestJS with Service Bus, same checks
terraform-azure-rabbitmq Phoenix with an external RabbitMQ, same checks
helm Django (relay Deployment): helm lint --strict, and helm template for every environment
ci-github, ci-github-kubernetes GitHub Actions with Terraform and with Helm deployment: actionlint (with shellcheck)
ci-gitlab, ci-azure-pipelines The official GitLab CI and Azure Pipelines schemas (check-jsonschema)

CI never runs terraform apply.