Terraform module that deploys the Ghost Agent Platform to a single EC2 VM in an AWS account. Provisions the VM, network, IAM, and AWS Secrets Manager entries, then bootstraps the stack via cloud-init - including cosign signature verification of the stack definition and images before any container starts.
- 1 EC2 instance (
t3.largeby default, AL2023 AMI) running the full Ghost Agent Platform stack as docker-compose. Five service containers: the gateway, credential proxy, worker, UI, and an in-stack updater that applies UI-driven upgrades (see Updating the deployment). - 1 EBS data volume (100 GB gp3 by default) mounted at
/var/lib/exo- holds the database, TLS material, run artifacts, and signing-cert state. Configured withprevent_destroy = trueto guard against accidental teardown. - 1 Elastic IP providing a stable public address.
- 1 Security group - SSH from
var.admin_cidronly, HTTP/HTTPS fromvar.public_ingress_cidrs(default: world). - 1 IAM role + instance profile with scoped permissions: ECR pull on Ghost's image repos, Secrets Manager read on this module's own secrets, SSM core (for Session Manager).
- 4 Secrets Manager entries: JWT secret, encryption key, seed admin password, optional Slack config. JWT and encryption key are auto-generated by Terraform.
- (Optional) Route 53 A record when both
var.domain_nameandvar.route53_zone_idare set.
- AWS account with admin or equivalent permissions to create EC2 / EBS / IAM / Secrets Manager resources.
- A public subnet in a VPC. The subnet must allow inbound HTTP/HTTPS for Let's Encrypt cert issuance and public app access.
- Cross-account ECR pull access from Ghost Security. Ghost publishes images to a private ECR registry; the AWS account running this module needs to be added to Ghost's repository policy before the EC2 can pull. See Onboarding below.
- Terraform >= 1.5 with the AWS provider 6.x.
- Share the target AWS account ID with Ghost Security. The account running this module needs to appear on Ghost's
pull_allowed_accountslist - without it,terraform applywill succeed but cloud-init will fail to pull images. - Ghost provides back the
image_registryURL (ECR registry hostname) and the releasedimage_tagto deploy. - Apply the module with those values plus a subnet, admin CIDR, and seed admin email.
module "ghost_agent" {
# Pin to a released tag for reproducibility (omit ?ref=… to track main).
source = "github.com/ghostsecurity/terraform-ghost-agent-platform?ref=<latest release tag, e.g. v0.1.7>"
# Provided by Ghost during onboarding
image_registry = "012345678901.dkr.ecr.<region>.amazonaws.com"
image_tag = "v1.0.0"
# Network placement
subnet_id = "subnet-0abc123def456789"
admin_cidr = "203.0.113.42/32"
# Initial admin user - password is auto-generated
seed_admin_email = "ops@example.com"
}
output "url" {
value = module.ghost_agent.bringup_url
}
# Access the VM. Uses AWS Systems Manager Session Manager - no SSH key
# pair needed (the module attaches the SSM role). For SSH instead, set
# `ssh_key_name` on the module and use `module.ghost_agent.ssh_command`.
output "ssm" {
value = module.ghost_agent.ssm_session_command
}This bring-up uses the default nip.io fallback for the public hostname (no DNS setup required) and pulls a real Let's Encrypt cert automatically. To use a custom FQDN instead, set domain_name and optionally route53_zone_id.
terraform apply returns as soon as the EC2 instance is created, but the on-VM bootstrap (mount EBS, install Docker + cosign, verify image signatures, pull images, start the stack) takes another ~3-5 minutes. Wait for cloud-init to finish before trying to log in - watch the bootstrap log and look for the final ===> Bootstrap complete at <timestamp> line.
Two ways to watch:
# From your laptop - no SSM/SSH needed. Console output is buffered
# by EC2 and typically lags 1-3 minutes, but works even before the
# SSM agent comes up:
aws ec2 get-console-output --region <region, e.g. us-east-1> \
--instance-id "$(terraform output -raw instance_id)" \
--latest --output text | tail -100# Real-time, via SSM Session Manager (requires the SSM agent to be
# running on the VM - happens a minute or two into bootstrap):
$(terraform output -raw ssm_session_command)
sudo tail -f /var/log/ghost-agent-bootstrap.logOnce the ===> Bootstrap complete line appears the stack is up. Useful terraform outputs:
terraform output bringup_url # e.g. https://3-14-15-92.nip.io
terraform output -raw ssm_session_command # aws ssm start-session --region <region> --target i-01234...
terraform output secret_arns # ARNs for the auto-generated secretsRetrieve the initial admin password:
aws secretsmanager get-secret-value \
--region <region> \
--secret-id "$(terraform output -json secret_arns | jq -r .seed_admin_password)" \
--query SecretString --output textOpen the bringup_url in a browser, sign in with seed_admin_email + the password above, then rotate the password in-app.
By default this module publishes an IAM role, <name_prefix>-ssm-support (e.g. ghost-agent-ssm-support), that Ghost Security assumes to open an AWS Systems Manager Session Manager shell on the VM for support. Its trust is scoped to a single Ghost support role, so only that role can assume it. The role grants only ssm:StartSession on this one instance - an interactive shell, or a port-forward to reach the UI without opening any inbound ports - plus management of the operator's own sessions: no SSH key, no inbound ports, and no access to any other resource. The instance still dials out to the SSM service exactly as it does for your own ssm_session_command; nothing new is exposed to the internet.
terraform output ssm_support_role_arn # the role Ghost assumes (empty if disabled)To turn it off, set ghost_support_access_enabled = false. The role is removed and Ghost has no Session Manager path to the VM.
Three paths, in order of operator overhead.
A workspace admin opens the UI's workspace dropdown → System → Version. The page shows the running tag and (after a check-against-ECR) the latest available release. Clicking Upgrade to vX.Y.Z dispatches the upgrade to the in-stack updater container, which:
- Fetches and
cosign verifys the release's stack bundle — thedocker-composefor that tag — from ECR - Applies it to
/opt/exoand rewritesTAG=in/opt/exo/.env - Pulls the release's images and converges the stack (
docker compose up -d --remove-orphans)
Because the compose travels with the release, an upgrade can add, remove, or reconfigure containers — not only change image tags. The updater excludes itself from the converge so it doesn't kill the container running the upgrade. Brief ~10-30s window during the gateway swap where the API may 502 - acceptable for a single-host deployment.
Rollback works the same way: the page offers a one-click rollback to the prior tag (derived from the audit log of past upgrades), fetching that tag's bundle so the topology reverts too.
When the updater itself needs a version bump (it doesn't auto-update), or as break-glass if the in-app path is broken:
$(terraform output -raw ssm_session_command) # or ssh_command
cd /opt/exo
sudo sed -i 's/^TAG=.*/TAG=vX.Y.Z/' .env
# The updater image is pinned separately; bump it to the same tag too:
sudo sed -i 's/^UPDATER_TAG=.*/UPDATER_TAG=vX.Y.Z/' .env
# Fetch the stack bundle (the docker-compose) for the target tag so the
# topology matches the release, then converge:
REGISTRY=$(grep '^REGISTRY=' .env | cut -d= -f2)
sudo oras pull "$REGISTRY/exo-stack:vX.Y.Z" -o /opt/exo
sudo docker compose -f docker-compose.prod.yml pull
sudo docker compose -f docker-compose.prod.yml up -d --remove-orphansECR repositories use immutable tags, so byte-identical reproduction is guaranteed.
Force a clean instance recycle by tainting the EC2 resource:
terraform apply -replace=module.ghost_agent.aws_instance.vmThe instance is replaced, cloud-init re-runs end-to-end (including cosign verification), but the data EBS volume and secrets persist. Useful when AMI / cloud-init changes need to land alongside a version change. Note: the var.image_tag value written to .env by cloud-init overrides whatever the running stack was on, so make sure image_tag in tfvars matches the version you want before triggering a replace.
The stack bundle (the docker-compose for a release) and every service image are cosign verify'd against Ghost's published-workflow identity before they are applied or started — at first boot and on every in-app upgrade. The verify policy is encoded in var.image_signing_identity_regex + var.image_signing_oidc_issuer. Defaults match the Ghost Security publish workflow on a v* tag:
identity-regexp: ^https://github\.com/ghostsecurity/exo/\.github/workflows/publish-to-ecr\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$
oidc-issuer: https://token.actions.githubusercontent.com
If a signature fails verification, cloud-init exits before docker compose up (and an in-app upgrade aborts before touching the stack). Cosign and the workflow signer are pinned in lockstep on both sides - Ghost's publish workflow signs with cosign-installer@v4.1.2 (cosign v3.0.6), and the cloud-init verify uses the same version.
| Symptom | Where to look |
|---|---|
terraform apply succeeds but the bringup URL doesn't respond |
Cloud-init log: sudo tail -200 /var/log/ghost-agent-bootstrap.log |
cosign verify failure in the bootstrap log |
Image-tag mismatch, or Ghost's signing identity regex doesn't match. Verify with cosign verify <image>@<digest> locally to confirm. |
RepositoryNotFoundException during image pull |
Cross-account ECR access not yet granted - contact Ghost. |
502 from Caddy for /api calls |
Gateway container down. docker compose -f /opt/exo/docker-compose.prod.yml ps and check gateway logs. |
| Browser cert error / "not secure" warning | LE issuance failed (port 80 unreachable, DNS not resolving, rate limited). Check docker logs <caddy-container>. |
| Database not initializing | First-boot replica-set bootstrap timing. Verify /var/lib/exo is actually the EBS mount (mountpoint /var/lib/exo) - if not, the bootstrap script aborts before docker starts. |
Credential-proxy / gateway fail at startup with cannot program address ... conflicts with existing route 0.0.0.0/0 (in the bootstrap log) |
Docker libnetwork regression in AL2023 docker 25.0.16 - it can't attach a container to multiple bridge networks when one is internal: true, which the proxy and gateway both use. The module pins Docker to 25.0.14 and version-locks it (DOCKER_VERSION in files/user_data.sh.tpl); if you hit this, Docker was bumped past the pin - re-pin and terraform apply -replace the instance. Only bump the pin once a fixed docker build lands in the AL2023 repo (confirm the proxy comes up on its three networks). |
terraform apply fails with secret with this name is already scheduled for deletion |
A previous terraform destroy puts secrets into a 7-day soft-delete (recovery_window_in_days = 7 in secrets.tf). To re-deploy into the same account before the window expires, force-delete the leftovers: for s in jwt-secret encryption-key seed-admin-password slack; do aws secretsmanager delete-secret --secret-id "ghost-agent/$s" --force-delete-without-recovery --region <region>; done |
Persistent log locations on the VM:
/var/log/ghost-agent-bootstrap.log- cloud-init bootstrap (single run, captured during first boot)/var/log/cloud-init-output.log- cloud-init's own logdocker logs <container>- runtime container logs (gateway, credential-proxy, worker, caddy, database)
The EBS data volume at /var/lib/exo holds all survival-critical state:
tls/- MITM CA private key + service CA private key. Loss of these invalidates every encrypted credential and every enrolled runner identity.mongo-data/- application database.caddy-data/- Let's Encrypt account and issued certificates.runner-identity/- per-runner client certs and slot locks.artifacts/- run output (recoverable, but loss is a regression).
The module sets prevent_destroy = true on this volume to guard against terraform destroy. That protects against accidental teardown, but not against data corruption, an accidental delete inside the VM, or loss of the availability zone - so production deployments should add scheduled snapshots.
Set enable_data_volume_snapshots = true and the module creates a Data Lifecycle Manager (DLM) policy that takes a daily snapshot of the data volume, retaining data_volume_snapshot_retention_days (30 by default), plus the DLM service role it runs as. The policy targets this deployment's volume by a dedicated tag, so it does not touch any other volume in the account.
module "ghost_agent" {
# ...
enable_data_volume_snapshots = true
data_volume_snapshot_retention_days = 30
}For a centralized backup policy across many resources, leave this off and target the data volume (tagged Name = "<deployment-name>-data") from your own AWS Backup plan instead.
EBS snapshots are crash-consistent, and MongoDB recovers a crash-consistent snapshot on startup via its journal. For an application-quiesced snapshot, stop the stack first (sudo docker compose -f /opt/exo/docker-compose.prod.yml down), snapshot, then bring it back up.
Restore swaps the data volume for a fresh one created from a snapshot, then reconciles Terraform state so the module adopts it. The instance keeps its Elastic IP (the URL is unchanged), and the restored volume auto-mounts on boot - the /etc/fstab entry matches by filesystem UUID, which a snapshot preserves, so there's no manual remount.
Run from the directory where the module is invoked. Set the region and deployment name, and pick a snapshot:
REGION=us-east-1
NAME=ghost-agent # var.name_prefix
# instance_id is a module output (surface it at your root, as the examples
# do); the data volume has no output, so look it up by its Name tag.
INSTANCE_ID=$(terraform output -raw instance_id)
OLD_VOL=$(aws ec2 describe-volumes --region "$REGION" \
--filters "Name=tag:Name,Values=${NAME}-data" --query 'Volumes[0].VolumeId' --output text)
AZ=$(aws ec2 describe-volumes --region "$REGION" --volume-ids "$OLD_VOL" \
--query 'Volumes[0].AvailabilityZone' --output text)
aws ec2 describe-snapshots --region "$REGION" --owner-ids self \
--filters "Name=volume-id,Values=${OLD_VOL}" \
--query 'reverse(sort_by(Snapshots,&StartTime))[].{Id:SnapshotId,Started:StartTime,State:State}' --output table
SNAP=snap-xxxx # a State=completed snapshot from the listStop the instance (for a clean detach), swap the volume, and start it again:
aws ec2 stop-instances --region "$REGION" --instance-ids "$INSTANCE_ID"
aws ec2 wait instance-stopped --region "$REGION" --instance-ids "$INSTANCE_ID"
aws ec2 detach-volume --region "$REGION" --volume-id "$OLD_VOL"
aws ec2 wait volume-available --region "$REGION" --volume-ids "$OLD_VOL"
NEW_VOL=$(aws ec2 create-volume --region "$REGION" --snapshot-id "$SNAP" \
--availability-zone "$AZ" --volume-type gp3 \
--tag-specifications "ResourceType=volume,Tags=[{Key=Name,Value=${NAME}-data},{Key=exo:snapshot-group,Value=${NAME}},{Key=ManagedBy,Value=terraform-ghost-agent-platform},{Key=Component,Value=ghost-agent-platform}]" \
--query VolumeId --output text)
aws ec2 wait volume-available --region "$REGION" --volume-ids "$NEW_VOL"
aws ec2 attach-volume --region "$REGION" --volume-id "$NEW_VOL" --instance-id "$INSTANCE_ID" --device /dev/sdf
aws ec2 wait volume-in-use --region "$REGION" --volume-ids "$NEW_VOL"
aws ec2 start-instances --region "$REGION" --instance-ids "$INSTANCE_ID"
aws ec2 wait instance-running --region "$REGION" --instance-ids "$INSTANCE_ID"Reconcile Terraform state so the module manages the new volume instead of the old one (adjust module.ghost_agent to your module instance name):
terraform state rm 'module.ghost_agent.aws_volume_attachment.data' 'module.ghost_agent.aws_ebs_volume.data'
terraform import 'module.ghost_agent.aws_ebs_volume.data' "$NEW_VOL"
terraform import 'module.ghost_agent.aws_volume_attachment.data' "/dev/sdf:${NEW_VOL}:${INSTANCE_ID}"
terraform plan # expect no changes (benign tag drift at most)Verify (via SSM: mountpoint /var/lib/exo is mounted and docker compose -f /opt/exo/docker-compose.prod.yml ps is healthy), then delete the old volume once you trust the restore:
aws ec2 delete-volume --region "$REGION" --volume-id "$OLD_VOL"Two things to get right: the new volume must be in the instance's availability zone (handled above), and you must reconcile Terraform state before the next terraform apply - skip it and the apply re-attaches the old volume, reverting the restore. Keep the old volume until the restore is verified; it is your rollback.
The volume holds MongoDB and the TLS CA keys, but stored credentials in MongoDB are ciphertext that only ENCRYPTION_KEY (in AWS Secrets Manager) can decrypt. Within the same account that secret persists independently of the volume, so a same-account restore needs nothing more. To rebuild in a different account, also copy ENCRYPTION_KEY and jwt-secret from Secrets Manager: without ENCRYPTION_KEY every stored credential is unrecoverable, and without jwt-secret all active sessions are invalidated.
Other notes:
- Treat the volume ID as sensitive - anyone with
ec2:DescribeVolumescan locate it. - For dev/test instances: temporarily set
prevent_destroy = false(one-line module edit) beforeterraform destroy.
See variables.tf for the full input list. Most useful overrides:
| Variable | When to override |
|---|---|
domain_name + route53_zone_id |
Real public domain instead of the nip.io fallback |
instance_type |
Higher throughput; in-place change supported (~1-2 min downtime, data persists) |
data_volume_size_gb |
Anticipate high workflow rate / artifact retention |
public_ingress_cidrs |
Lock down 80/443 to a CDN/WAF origin (note: scoping 80 too tightly will break Let's Encrypt cert renewal) |
ssh_key_name |
Use SSH instead of SSM Session Manager |
enable_data_volume_snapshots |
Turn on daily DR snapshots of the data volume (with data_volume_snapshot_retention_days) |
ghost_support_access_enabled |
Set false to remove the Ghost support role and disable cross-account SSM access |
image_signing_identity_regex |
Ghost rotates the publish workflow path or pre-release tag pattern |
A few things this module does not enforce, but which any production deploy should have in place:
- Tighten
admin_cidrto an office IP or VPN egress range. The variable validates that the value is a CIDR, not that it's narrow. - Use a remote Terraform backend (S3 with KMS encryption). The module generates secrets via
random_*resources, so their values live in state - local state on a laptop is not appropriate. - Enable scheduled snapshots of the data volume by setting
enable_data_volume_snapshots = true(see Data persistence and recovery). The volume holds all survival-critical state;prevent_destroyguards against accidental teardown but not against corruption or accidental file deletion inside the VM. - Add CloudWatch alarms on EC2 status checks and configure instance auto-recovery. A single-VM deployment has no built-in failover.
Apache License 2.0 - see LICENSE.
Copyright 2026 Ghost Security