Deployment Runbook · Infra as Code

Deploying Axia Security with Terraform

An end-to-end guide for standing up a self-hosted Axia platform in a dedicated AWS account — from account bootstrap through Terraform infrastructure to running application services and verification.

Delivered as · deploy bundle Target · dedicated AWS account Scope · infra + app, end-to-end Topology · 2 EC2 hosts Rev · 2 — adds Purview + Cursor

01 What you are deploying

Axia is a security-event analysis platform. It ingests events from data sources (Microsoft 365 / Outlook, Microsoft Purview DLP, Cursor and other webhook sources, API), enriches and classifies them, detects sensitive-data exposure with an LLM, and surfaces cases in a web dashboard. Integration setup for Microsoft Purview and Cursor is in §9.

Target architecture (per AWS account)

                       AWS Account  ·  region of your choice
┌──────────────────────────────────────────────────────────────────────────┐
│  VPC 10.0.0.0/16   ·  3 AZs  ·  3 public /20  ·  3 private /20  ·  1 NAT    │
│                                                                            │
│   ┌── private subnet ───────────────┐   ┌── private subnet ────────────┐   │
│   │  Platform EC2 (t3a.large)        │   │  Classifier / ML EC2         │   │
│   │  Ubuntu 22.04 + Docker           │   │  (c7i-flex.4xlarge)          │   │
│   │  compose: kafka · redis ·        │◄─►│  compose: llm-service ·      │   │
│   │  object-storage · pipeline ·     │   │  parser-service · rapidocr · │   │
│   │  domain services · app layer     │   │  data-classification         │   │
│   └───────────┬─────────────────────┘   └──────────────┬───────────────┘   │
│               │ 27017 (TLS)                             │                   │
│        ┌──────▼──────────┐                     ┌────────▼────────┐          │
│        │ DocumentDB       │                     │  AWS Bedrock     │          │
│        │ (MongoDB-compat) │                     │  (Claude models) │          │
│        └──────────────────┘                     └──────────────────┘          │
│        ┌──────────────────┐    Hosts reachable only via SSM Session Manager  │
│        │ S3 content-store  │    (no public SSH — EC2 lives in private subnets)│
│        └──────────────────┘                                                  │
└──────────────────────────────────────────────────────────────────────────┘
        ▲ images pulled cross-account from Axia ECR
        └ <AXIA_ECR_REGISTRY>/<service>:<tag>   (registry provided by Axia)

Two hosts

The full product runs across two EC2 instances, each with its own docker-compose file that communicate over private-IP URLs.

Platform EC2 compose/platform/docker-compose.yml

Kafka, Redis, object-storage, the ingestion pipeline (connectors + enrichment workers + event-analyzer + webhook), domain services (case, chat, events, engagement), and the app layer (application-backend/frontend, legacy app-backend/dashboard).

Classifier / ML EC2 compose/ml/docker-compose.yml

llm-service (Bedrock gateway), parser-service, rapidocr (OCR), and data-classification. Wired to the platform host via LLM_SERVICE_URL, PARSER_SERVICE_URL, and KAFKA_ADVERTISED_HOST.

Terraform vs. the running app

Terraform only builds AWS infrastructure; it never starts the application. The application layer runs as docker-compose on the hosts, configured from Terraform outputs.

Terraform provisionsApplication layer
VPC, subnets, NAT, security groupsKafka, Redis, all Axia service containers
Platform EC2 + Classifier EC2 (Docker pre-installed)Started with docker compose up -d over SSM
DocumentDB cluster (the "MongoDB")Config from TF outputs + client-config.env
S3 content-store bucket + credentials
IAM roles (ECR pull, Bedrock, SSM, CloudWatch, S3)
CloudWatch log groups

02 Prerequisites

The deployment bundle

Axia ships a self-contained deployment bundle — you do not clone the source repos. Unpack it and run every command below from inside it:

axia-deploy-<version>/
├── terraform/
│   ├── init/            # terraform-backend.yaml, github-actions-role.yaml (bootstrap)
│   ├── vpc/             # network stack
│   ├── platform/        # EC2 + DocumentDB + S3 + IAM  (+ example.backend.hcl, example.tfvars)
│   └── scripts/         # generate-deployment-config.sh, deploy.sh, ECR-cross-account-trust.sh
├── compose/
│   ├── platform/docker-compose.yml   # platform host services
│   └── ml/docker-compose.yml         # ML/classifier host services
└── client-config.env.template
Not in the bundle · Axia-internal

The apps-ci/ and tenants/ Terraform stacks (they run only in Axia's ECR-hosting account), Axia's own per-environment *.tfvars/*.backend.hcl and account IDs, the GitHub Actions CI workflows, and repo history. The bundle's platform/ stack ships an example.backend.hcl and example.tfvars to copy; the scripts are pre-fixed to bundle-relative paths.

Operator workstation tooling

ToolPurpose
aws CLI v2bootstrap, Terraform auth, SSM sessions, reading outputs
Terraform ≥ 1.0 (AWS provider ≥ 6.10.0)provisioning
jqgenerating deployment config from TF outputs
python3password URL-encoding in the config generator
Dockerlocal testing only
unzipunpacking the Axia deployment bundle
Session Manager pluginconnecting to the private-subnet hosts

AWS prerequisites

  • A dedicated AWS account for this deployment (each environment = its own account).
  • Admin access for the one-time bootstrap, then the scoped deployment role for applies.
  • Bedrock model access enabled in the region for the Claude models used by llm-service and chat-service.
  • Pick one region and use it consistently across the whole deployment.
Axia-side dependencies

Container images live in Axia's ECR and are pulled cross-account. A self-hosted deployment therefore requires Axia to (1) grant cross-account ECR pull for your account, (2) provide the bootstrap IAM policy JSONs (trust.json and the role permission docs — distributed by Axia, not in the repo), and optionally (3) publish tagged image releases.

Data-source credentials (collect before deploy)

  • Microsoft 365 / Outlook: Entra app — tenant ID, client ID, client secret (Graph permissions + admin consent), org domain.
  • Microsoft Purview DLP: an Entra app with Graph SecurityAlert.Read.All (can reuse the Outlook app). Full steps in §9.1.
  • Cursor (user-prompt events): captured via Cursor's local beforeSubmitPrompt hook deployed to developer endpoints, forwarding to the webhook receiver — you set a webhook source secret. Fleet-wide deployment needs Cursor Enterprise. See §9.2.
  • Webhook / API: no third-party credentials — configure source/topic settings in client-config.env.
  • SMTP (optional reviewer-alert email): host / port / user / password / from-address.

03 Phase 0 — AWS account bootstrap

One-time, with admin credentials. Creates the Terraform state backend and the IAM identities Terraform assumes.

Which bootstrap path

Two variants exist. Use the CloudFormation path below — it also creates the GitHub OIDC provider that the platform IAM stack depends on. (The CLI-only variant in CLIENT-AWS-SETUP.md creates just the S3 bucket and expects Axia-supplied role JSONs.)

Set your context

bash
export AWS_REGION=<your-region>
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "Deploying to account ${ACCOUNT_ID} in ${AWS_REGION}"

Create the Terraform state backend (S3 + DynamoDB lock)

bash · axia-deploy/terraform
aws cloudformation create-stack \
  --stack-name terraform-backend \
  --template-body file://init/terraform-backend.yaml \
  --region ${AWS_REGION}

# confirm
aws cloudformation describe-stacks --stack-name terraform-backend \
  --region ${AWS_REGION} --query 'Stacks[0].StackStatus'

Yields the state bucket by convention: terraform-state-<ACCOUNT_ID>-<REGION>, versioned with public access blocked.

Create the GitHub OIDC provider + deploy role

bash
aws cloudformation create-stack \
  --stack-name terraform-iac-github-actions-role \
  --template-body file://init/github-actions-role.yaml \
  --region ${AWS_REGION} \
  --parameters \
    ParameterKey=GitHubOwner,ParameterValue=Axia-Security \
    ParameterKey=GitHubRepo,ParameterValue=infra \
    ParameterKey=Region,ParameterValue=${AWS_REGION} \
  --capabilities CAPABILITY_NAMED_IAM

# get the deploy role ARN
aws cloudformation describe-stacks --stack-name terraform-iac-github-actions-role \
  --region ${AWS_REGION} \
  --query 'Stacks[0].Outputs[?OutputKey==`RoleArn`].OutputValue' --output text
Ordering

The OIDC provider (token.actions.githubusercontent.com) must exist before the platform and apps-ci stacks apply — their IAM roles reference it.

04 Phase 1 — Terraform infrastructure

Apply the two bundled stacks in order: vpc/ → platform/. Backend convention for every stack: bucket terraform-state-<ACCOUNT_ID>-<REGION>, key <stack>/terraform.tfstate.

Per-environment config files

Create a backend file per stack (copy the bundled terraform/platform/example.backend.hcl):

hcl · <env>.backend.hcl
bucket = "terraform-state-<ACCOUNT_ID>-<REGION>"
key    = "platform/terraform.tfstate"
region = "<REGION>"

And a <env>.tfvars (copy from the bundled example.tfvars):

hcl · <env>.tfvars
enable_alerts = false   # true to enable CloudTrail/SNS/CloudWatch alarms

roles_allowed_to_assume_platform_role = [
  # SSO/admin role names in THIS account allowed to assume the platform EC2 role
]

Other platform variables: region (set to your chosen region), project_name (platform, drives resource names + EC2 Name tag), documentdb_instance_class (db.t3.medium), documentdb_instance_count (1), documentdb_engine_version (8.0.0), classifier_instance_type (c7i-flex.4xlarge), classifier_volume_size (200 GB), and — when enable_alerts = true — alerts_email + alerts_client_name.

Apply the VPC stack

bash · axia-deploy/terraform/vpc
terraform init -reconfigure \
  -backend-config="bucket=terraform-state-${ACCOUNT_ID}-${AWS_REGION}" \
  -backend-config="key=vpc/terraform.tfstate" \
  -backend-config="region=${AWS_REGION}"
terraform plan  -var="region=${AWS_REGION}"
terraform apply -var="region=${AWS_REGION}"

Creates 10.0.0.0/16 with 3 public + 3 private /20 subnets across 3 AZs and a NAT gateway. Its output is consumed by the platform stack via remote state.

Apply the platform stack

bash · axia-deploy/terraform/platform
terraform init -reconfigure -backend-config=<env>.backend.hcl
terraform plan  -var-file=<env>.tfvars
terraform apply -var-file=<env>.tfvars

Reads the VPC remote state and creates: the Platform EC2 (t3a.large, Ubuntu 22.04, gp3 100 GB encrypted, private subnet, Docker bootstrapped by user_data.sh), the Classifier EC2, the DocumentDB cluster (master docdbadmin, password auto-managed in Secrets Manager, TLS + encryption + 7-day backups + deletion protection), the S3 content-store bucket and access user, all IAM roles/policies, and CloudWatch log groups.

Capture the outputs you need for the app layer:

bash · terraform output
terraform output ssm_command                        # connect to platform host
terraform output documentdb_cluster_endpoint
terraform output documentdb_get_password_command
terraform output content_store_bucket_name
terraform output s3_content_access_get_credentials_command
terraform output classifier_ssm_command             # connect to ML host

Axia-side stacks (not in the bundle)

Two further Terraform stacks — apps-ci/ (ECR repositories + shared CI OIDC role) and tenants/ (per-tenant read-only ECR-pull IAM users) — run only in Axia's ECR-hosting account. They are intentionally excluded from the deployment bundle; your account pulls images cross-account instead (§5). No action required from you.

05 Phase 2 — Cross-account ECR access

For a self-hosted account to pull images from Axia's ECR, Axia runs ECR-cross-account-trust.sh (Axia-side), which sets a repository policy on each service repo allowing your account root to BatchCheckLayerAvailability, GetDownloadUrlForLayer, and BatchGetImage.

Two halves

The platform EC2 IAM role already grants the pull actions; the cross-account repository policy is the missing half that Axia must add for your account ID. Without it, pulls are denied.

06 Phase 3 — Deploy the application layer

The application is delivered onto the EC2 host as a docker-compose stack. Two paths:

  • Manual (recommended for self-host): generate config from TF outputs, copy it and the compose file to the host over SSM, run the deploy script.
  • CI (Axia-managed): the platform-deploy.yml workflow assumes the deploy role via OIDC, finds the instance by Name tag, and runs docker compose up -d over SSM using GitHub Environment vars/secrets.

Generate the deployment config

bash · axia-deploy/terraform/platform
../scripts/generate-deployment-config.sh <CLIENT_NAME>

It reads terraform output, fetches the DocumentDB password from Secrets Manager and S3 credentials from SSM, URL-encodes the password, and writes deployment-config-<CLIENT>.json (account, region, ECR registry, DocumentDB endpoint, full TLS mongodb_uri, content-store bucket + region, S3 access/secret keys).

Pre-configured

The bundled generate-deployment-config.sh resolves the platform stack relative to the bundle — no path editing required. The ECR registry/region in the output point at the Axia ECR account by design (that is where images are pulled from cross-account, §5).

The MongoDB URI uses DocumentDB TLS form:

mongodb uri
mongodb://docdbadmin:<url-encoded-pw>@<endpoint>:27017/axia_security?tls=true
  &replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=false
  &tlsCAFile=/app/certs/global-bundle.pem&authMechanism=SCRAM-SHA-1

Create client-config.env

Copy client-config.env.template (bundle root) to client-config.env and fill in the per-client, non-generated values (the data-source credentials from §2). Leave a connector's section commented if you are not enabling it.

Copy files to the host and deploy

bash · connect via SSM
$(terraform output -raw ssm_command)   # aws ssm start-session --target i-xxxx …

Place these three files in ~/axia/ on the host — the deploy script requires all three:

  • deployment-config.json (from above)
  • client-config.env
  • docker-compose.yml — use the bundled compose/platform/docker-compose.yml

Then run the deploy script on the host. It installs jq/python3 if missing, downloads the DocumentDB CA bundle to ~/axia/certs/global-bundle.pem, logs Docker in to ECR, generates ~/axia/.env (mapping config → service env keys, fanning the Mongo URI to the services, setting the Bedrock LLM_MODEL, KAFKA_BOOTSTRAP_SERVERS=kafka:29092, and the S3/bucket vars) and appends your entire client-config.env, then runs docker compose pull and up -d.

bash · on the host, in ~/axia
./deploy.sh
docker compose ps

The script is idempotent — safe to re-run for upgrades.

07 Phase 4 — Deploy the ML / Classifier host

Repeat the deploy on the Classifier EC2 using the bundled compose/ml/docker-compose.yml, which runs llm-service, parser-service, rapidocr, and data-classification.

bash
$(terraform output -raw classifier_ssm_command)   # connect to the ML host
  • Bedrock must be reachable from the instance role, with AWS_DEFAULT_REGION set (a missing region is the classic NoRegionError for llm-service).
  • Mount the DocumentDB CA bundle at /app/certs/global-bundle.pem (data-classification needs it).
  • The ML box reaches platform Kafka via KAFKA_ADVERTISED_HOST = platform private IP; platform services reach the ML box via LLM_SERVICE_URL / PARSER_SERVICE_URL = ML private IP.

08 Phase 5 — Verification

bash · on each host
docker compose ps                                    # all Up / healthy
docker compose logs --tail 100 | grep -iE 'error|exception|failed'
ComponentPortCheck
dashboard (v1 UI)5173HTTP 200 on /
application-frontend (v2 UI)5174HTTP 200 on /
application-backend (v2 API)8088/health
app-backend (legacy v1)8003/health
object-storage-service8080/
events-service8001/healthz
case-service8005/
chat-service8008/
engagement-service8010/
webhook-connector8011/
kafka-ui8081web UI
llm-service (ML host)8000/health
parser-service (ML host)8090/

To reach a UI or DocumentDB from your workstation, use SSM port-forwarding (the platform stack prints ready-made commands):

bash
terraform output documentdb_ssm_port_forward_command   # localhost:27017 → DocumentDB

09 Data-source integrations

Axia ingests external events through connectors. This section covers the two integrations for this deployment: Microsoft Purview DLP (Axia polls Microsoft Graph outbound) and Cursor user-prompt events (forwarded to Axia's webhook receiver by a hook on each developer's machine). Both are additive — configure only what you use. All values below go in client-config.env (§6.2).

9.1 · Microsoft Purview DLP

How it works. The connector-purview service (the shared connector image with CONNECTOR_TYPE=purview) authenticates to Microsoft with the OAuth2 client-credentials flow and polls the Microsoft Graph Security API for DLP alerts. Each alert is published to the dlp.events Kafka topic, where the existing enrichment-worker-dlp consumes it. The connector reaches out to Microsoft — no inbound network exposure is needed.

microsoft graph
GET https://graph.microsoft.com/v1.0/security/alerts_v2
    ?$filter=serviceSource eq 'dataLossPrevention'
# incremental polls add:  and lastUpdateDateTime gt <timestamp>
# results are paged via @odata.nextLink
Purview-side prerequisites

DLP policies must be configured and enabled in the Microsoft Purview portal with rules set to generate alerts (no policies → no alerts). Single-event alerts work on any DLP subscription; aggregated/threshold alerts require E5 / the Purview compliance tier. New or modified policies can take up to ~3 hours to start generating alerts.

Azure Entra app registration (one-time, in the customer tenant):

  1. Entra admin center → App registrations → New registration. Name it (e.g. "Axia Purview DLP Connector"), single-tenant, no redirect URI. Register.
  2. On Overview, copy Application (client) ID → MICROSOFT_CLIENT_ID and Directory (tenant) ID → MICROSOFT_TENANT_ID.
  3. Certificates & secrets → New client secret → copy the Value (shown once) → MICROSOFT_CLIENT_SECRET.
  4. API permissions → Add a permission → Microsoft Graph → Application permissions → add SecurityAlert.Read.All.
  5. Grant admin consent for the tenant — required, or the token won't carry the permission.
Reuse the Outlook app

You can reuse the same Entra app as the Outlook connector: just add the SecurityAlert.Read.All permission to it and reuse the same tenant/client/secret.

Configure the connector (client-config.env):

client-config.env · purview
MICROSOFT_TENANT_ID=<tenant-id>
MICROSOFT_CLIENT_ID=<client-id>
MICROSOFT_CLIENT_SECRET=<client-secret>
PURVIEW_START_DATE=            # optional backfill (e.g. 2026-01-01); blank = from now

Stack-provided defaults you normally leave alone: MICROSOFT_GRAPH_BASE_URL=https://graph.microsoft.com/v1.0, MICROSOFT_AUTHORITY_BASE_URL=https://login.microsoftonline.com, PURVIEW_PENDING_QUEUE_NAME=dlp.events.

Verify. docker compose logs -f connector-purview shows a successful token fetch and poll; DLP alerts land on the dlp.events topic (visible in kafka-ui) and flow through enrichment-worker-dlp into the events service.

Microsoft references: list alerts_v2 · serviceSource enum · client-credentials / .default · DLP alerts prerequisites & licensing.

9.2 · Cursor (user-prompt events)

We ingest only the user-prompt events — the text developers submit to Cursor's AI — for sensitive-data detection.

How it works. Unlike Purview, you don't point Cursor at a URL — Cursor has no cloud-side webhook or API that emits user prompts (its only outbound webhook is Cloud-Agent status; its Admin API, audit logs, and code-tracking exports all exclude prompt text). Prompt capture is done with a local Cursor hook — beforeSubmitPrompt — that runs a small script on each developer's machine right after the user hits send; the script receives the prompt as JSON on stdin and forwards it to Axia's webhook receiver. Because your script makes the call (not Cursor's cloud), it sets the static X-Webhook-Secret header itself, so it's directly compatible with the receiver — no HMAC shim. Enabling only user-prompt events means declaring only the beforeSubmitPrompt hook.

flow
Developer's Cursor ──beforeSubmitPrompt hook──▶ forwarder script ──HTTPS POST──▶ webhook-connector :8011
   (prompt text)                                (adds X-Webhook-Secret)          POST /api/v1/webhooks/cursor
                                                                                 → enrichment.pending
                                                                                 → enrichment-worker-webhook

Step 1 — register the source on the Axia side (client-config.env):

client-config.env · webhook source
# name:secret pairs, ';'-separated (this is the static X-Webhook-Secret value)
WEBHOOK_ENRICHMENT_SOURCES=cursor:<axia-webhook-secret>
WEBHOOK_ENRICHMENT_TOPIC=enrichment.pending

Step 2 — expose the receiver to developer machines. The receiver (:8011) sits on the private-subnet platform host, so front it with an HTTPS ingress (ALB / reverse proxy) reachable from where developers run Cursor (corporate network / VPN, or internet for remote staff). Terminate TLS at the ingress; forward POST /api/v1/webhooks/cursor.

Step 3 — deploy the Cursor hook to developer endpoints. Add a hooks.json plus a forwarder script. Scope options: ~/.cursor/hooks.json (per user), <project>/.cursor/hooks.json (per repo, committable), or Enterprise-managed hooks pushed from the Cursor team dashboard / MDM for tamper-resistant fleet-wide rollout.

hooks.json — declare only the prompt hook
{
  "version": 1,
  "hooks": {
    "beforeSubmitPrompt": [{ "command": ".cursor/hooks/forward-prompt.sh" }]
  }
}
forward-prompt.sh
#!/usr/bin/env bash
payload="$(cat)"                                   # beforeSubmitPrompt event on stdin
gen_id="$(printf '%s' "$payload" | jq -r '.generation_id // empty')"
curl -s -X POST "https://<ingress-host>/api/v1/webhooks/cursor" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Secret: <axia-webhook-secret>" \
  -H "X-Idempotency-Key: ${gen_id}" \
  --data "$payload" >/dev/null
echo '{"continue": true}'    # allow the prompt; leave failClosed off so errors never block

Payload & idempotency. The beforeSubmitPrompt event carries prompt (the text), user_email, conversation_id, generation_id, attachments[], workspace_roots[], hook_event_name. There is no timestamp (stamp on receipt) and no id/event_id field — pass generation_id as X-Idempotency-Key (as above) so the receiver dedupes per prompt; conversation_id + generation_id is the stable composite key.

Enablement & privacy

Central config & enforcement via MDM. Deploy and enforce the hook fleet-wide with your MDM (and/or Cursor Enterprise managed hooks from the team dashboard) so it is applied consistently and developers cannot remove or bypass it. Ad-hoc per-repo or per-user hooks.json is fine for testing but is removable by developers. Capturing prompt text is user-input monitoring — handle as a consent/policy matter and align with Cursor Privacy Mode. Leave failClosed off so a forwarder error never blocks a developer.

Verify. Submit a prompt in Cursor; the forwarder POSTs and the receiver returns 202 Accepted {"status":"accepted","source":"cursor",…}; the event appears on enrichment.pending and is processed by enrichment-worker-webhook.

Cursor references: Hooks (beforeSubmitPrompt) · LLM Safety & Controls — hooks-based DLP.

10 Configuration reference

Service & port map

PortServiceHostRole
5173dashboardplatformFrontend v1 (nginx :80)
5174application-frontendplatformFrontend v2 (nginx)
8088application-backendplatformFastAPI backend v2 (int. :8080)
8003app-backendplatformLegacy NestJS backend v1 (int. :3000)
8080object-storage-serviceplatformS3-fronting object storage API
8001events-serviceplatformEvents storage API
8005case-serviceplatformCase/analysis persistence
8008chat-serviceplatformIntel chat (Bedrock agent)
8010engagement-serviceplatformOutbound ticketing (Jira/ADO)
8011webhook-connectorplatformWebhook ingest → Kafka
8004connector-apiplatformAPI ingestion connector
9092kafkaplatformBroker (int. :29092, KRaft)
6379redisplatformPub/sub + config-plane listener
8000llm-serviceMLBedrock LLM gateway / detection
8090parser-serviceMLContent parser
8829rapidocrMLOCR backend (internal)
27017DocumentDBmanagedMongoDB-compatible datastore

Container images

Public: confluentinc/cp-kafka:8.1.1, ghcr.io/kafbat/kafka-ui:v1.4.2, redis:7.4-alpine.

Axia (ECR): object-storage-service, event-analyzer, enrichment-worker, connector, webhook, case-service, chat-service, events-service, engagement-service, application-backend, application-frontend, app-backend, dashboard; and on the ML host llm-service, parser-service, parser-rapidocr, data-classification. Registry pattern <AXIA_ECR_REGISTRY>/<service>:<tag> (exact registry provided by Axia) — tags pinned per release (e.g. v1.4.0 core / v1.0.0 app-layer); un-pinned entries default to latest.

Key environment variables & secrets

Config uses a namespaced convention: SERVICES__ENV__<SERVICE>__<VAR> (non-secret) and SERVICES__SECRETS__<SERVICE>__<VAR> (secret).

CategoryVariables
AWS / registryACCOUNT_ID, AWS_REGION, ECR_REGISTRY
DatabaseMONGODB_URI / per-service SERVICES__SECRETS__{APP_BACKEND_SERVICE,EVENTS_SERVICE,CHAT_SERVICE,INGESTION_SERVICE}__(MONGODB_URI|DB_URI)
Object storageSERVICES__SECRETS__OBJECT_STORAGE_SERVICE__AWS_ACCESS_KEY_ID / __AWS_SECRET_ACCESS_KEY, ...__URL, ...__REGION, DATA_BUCKET, AXIA_BUCKET
AuthJWT_SECRET (required for application-backend), AUTH_COOKIE_SECURE
LLMLLM_MODEL (Bedrock model id), PROMPT_PATH, AWS_DEFAULT_REGION
KafkaKAFKA_BOOTSTRAP_SERVERS, KAFKA_SECURITY_PROTOCOL, KAFKA_ADVERTISED_HOST
Cross-hostPARSER_SERVICE_URL, LLM_SERVICE_URL
Outlook / 365MICROSOFT_TENANT_ID, MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, OUTLOOK_ORG_DOMAIN
Purview DLP (§9.1)MICROSOFT_TENANT_ID, MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, PURVIEW_START_DATE; defaults MICROSOFT_GRAPH_BASE_URL, PURVIEW_PENDING_QUEUE_NAME=dlp.events
Webhook — Cursor (§9.2)WEBHOOK_ENRICHMENT_SOURCES (cursor:<secret>;…), WEBHOOK_ENRICHMENT_TOPIC, WEBHOOK_DLP_SOURCES, WEBHOOK_DLP_TOPIC, WEBHOOK_BATCH_SOURCES, WEBHOOK_BATCH_TOPIC
SMTP (optional)SMTP_HOST/PORT/SECURE/USER/PASSWORD/FROM_ADDRESS/FROM_NAME
Secrets sourcing

In production the DocumentDB password comes from Secrets Manager and the S3 credentials from SSM Parameter Store — both retrieved at deploy time by the config generator, never stored in the repo.

11 Operations

  • Connect to a host: aws ssm start-session --target <instance-id> --region <region> (or terraform output ssm_command). Hosts are in private subnets — no SSH.
  • DocumentDB shell from your laptop: use documentdb_ssm_port_forward_command to map localhost:27017, then connect with a TLS client using the RDS CA bundle (authMechanism=SCRAM-SHA-1).
  • Upgrade a service: update the image tag (via .env on the host or the CI SERVICES_ENV_VARS), then re-run the deploy (docker compose pull && up -d).
  • Logs: docker compose logs -f <service> on the host; container logs also ship to CloudWatch (/platform/services, /ml).
  • Off-hours shutdown: the platform EC2 can be tagged shut-down=true to opt into an off-hours scheduler (optional cost control).

12 Known gotchas & caveats

Read before you deploy

These are the failure modes most likely to bite a first deployment.

  1. Two hosts required. The platform compose file does not include llm-service or the parser — those live on the ML host. Both must run and be wired by private-IP URLs.
  2. DocumentDB, not plain MongoDB. Connections require TLS + the RDS CA bundle at /app/certs/global-bundle.pem and authMechanism=SCRAM-SHA-1. URL-encode the password (scripts/encode-mongodb-password.py).
  3. Bedrock region. llm-service and chat-service need AWS_DEFAULT_REGION set, or they fail with NoRegionError. Verify the model id against aws bedrock list-foundation-models.
  4. The image registry points at Axia's ECR in the generated deployment-config.json by design — that is where images are pulled from cross-account (§5), not your account. The bundled config generator and deploy.sh are already path-fixed and correctly named.
  5. Region consistency. Pin one region across the whole deployment — state bucket, VPC, DocumentDB, S3, and the deployment config — and set it before you apply.
  6. Cross-account ECR trust is Axia-side. The host role grants pull actions, but Axia must add your account to each repository policy.
  7. AMI / user_data drift. The EC2 resources set ignore_changes = [ami, user_data], so Terraform will not replace a running host on AMI updates. Use targeted applies for non-EC2 changes.
  8. Bootstrap variants differ. Prefer the CloudFormation bootstrap (creates the OIDC provider the IAM stacks depend on).
  9. CI deploy is Axia-managed. The GitHub Actions deploy path runs from Axia's org; the manual deploy path documented here is the supported route for self-hosted deployments.
  10. Purview alerts can lag. New/modified DLP policies take up to ~3 hours to start generating alerts, and app-only reads require SecurityAlert.Read.All with admin consent — a missing consent looks like "authenticated but zero alerts" (§9.1).
  11. Cursor has no cloud-side prompt events. User prompts are captured only via the local beforeSubmitPrompt hook deployed to each developer endpoint (configure and enforce it via MDM for a tamper-resistant fleet-wide rollout). The hook's forwarder script sets X-Webhook-Secret itself, so no HMAC shim is needed for this path (§9.2). Capturing prompt text is user-input monitoring — treat as a consent/policy matter.

13 Bundle contents map

Everything the phases above reference, and where it lives inside axia-deploy-<version>/:

TopicPath (in the bundle)
Bootstrap CloudFormationterraform/init/{terraform-backend.yaml,github-actions-role.yaml}
Network stackterraform/vpc/
Core infra stack (EC2 / DocumentDB / S3 / IAM)terraform/platform/
Host bootstrap scriptterraform/platform/scripts/user_data.sh
Example env configterraform/platform/{example.backend.hcl,example.tfvars}
Deploy scriptsterraform/scripts/{generate-deployment-config.sh,deploy.sh,ECR-cross-account-trust.sh}
Client config templateclient-config.env.template
Platform host composecompose/platform/docker-compose.yml
ML host composecompose/ml/docker-compose.yml

Axia-internal references (not shipped): the apps-ci/tenants Terraform stacks, the Terraform source-of-truth map, the CI deploy workflow (platform-deploy.yml), and the internal deployment-strategy / ports docs.