Skip to content

Configuration Reference

All infrastructure configuration lives in Pulumi.<stack-name>.yaml. See Pulumi.example.yaml for a fully documented reference with all available options.

Required Settings

Config Key Description Example
hawk:domain Root domain — Hawk prepends hawk. to form service URLs (e.g. api.hawk.example.com) example.com
hawk:publicDomain Public domain for DNS zones example.com
hawk:primarySubnetCidr VPC CIDR block 10.0.0.0/16

Domain & DNS

Hawk's services live under hawk.<hawk:domain> — for example, with hawk:domain: example.com the API is at api.hawk.example.com (Pulumi adds the hawk. segment automatically; do not include it in the config value). DNS delegation must work before ACM TLS certificates can validate. Four paths — pick one before deploying:

How hawk:domain and hawk:publicDomain must relate

Service hostnames are built as *.hawk.<hawk:domain> and the load balancer's certificates cover platform.<hawk:domain> and *.hawk.<hawk:domain> — and both certificates validate via DNS records in the hawk:publicDomain Route 53 zone. So hawk:domain must equal or be a subdomain of hawk:publicDomain; anything else puts the validation records in a zone that can't serve them, and cert validation hangs (preflight checks this).

When you delegate only a single subdomain (e.g. a Cloudflare parent with just hawk.example.com delegated to Route 53), set both values to that subdomain — hawk:domain: hawk.example.com and hawk:publicDomain: hawk.example.com. Service URLs then look like api.hawk.hawk.example.com (the doubled hawk is cosmetic and expected).

Option When What to set
A. Route 53 Domains New setup, simplest path. AWS handles registration, hosted zone, and delegation in a single step. hawk:createPublicZone: "false" (the default) — Pulumi looks up the existing zone created by registration.
B. Existing registrar + manual delegation You already own the domain at Namecheap/GoDaddy/etc. and want to keep it there. Create/import the Route 53 public hosted zone first, update your registrar to its four NS records, and leave hawk:createPublicZone: "false".
C. Cloudflare delegation Parent domain is in Cloudflare and you want a subdomain delegated to AWS. Create/import the Route 53 public hosted zone first, then create NS records in Cloudflare before deploy.
D. HTTP-only (testing only) Smoke-testing without a real domain. hawk:skipTlsCerts: "true". Services reachable only via the raw ALB DNS name. Not for real use.

Cert-validation hang gotcha

If DNS isn't working when pulumi up runs, the wildcard ACM certificate validation will hang for ~75 minutes (default timeout) before failing. Get delegation in place first and run scripts/dev/preflight.sh before deploy.

Leave createPublicZone disabled for strict preflight

scripts/dev/preflight.sh validates that hawk:publicDomain is already delegated to the expected Route 53 hosted zone before pulumi up. If hawk:createPublicZone: "true", Pulumi would create a new zone whose NS records cannot be known or delegated before the run, so strict preflight fails. Create/import the public hosted zone first, delegate DNS, and leave createPublicZone: "false".

Authentication

When hawk:oidcClientId is not set, Hawk provisions a Cognito user pool during pulumi up and uses it as the auth provider. Create your first user with scripts/dev/create-cognito-user.sh <stack> <email> after the deploy finishes.

To use your own OIDC provider (Okta, Auth0, etc.), set all three of these — Hawk will skip the Cognito setup:

Config Key Description Example
hawk:oidcClientId OIDC client ID your-client-id
hawk:oidcAudience OIDC audience for access tokens https://api.example.com
hawk:oidcIssuer OIDC issuer URL https://login.example.com/oauth2/default

The IdP app itself needs specific grant types, redirect URIs, and JWT claims — see Security: OIDC App Requirements for what to configure in Okta/Auth0/etc. before deploying.

CPU architecture

hawk:cpuArchitecture selects the architecture for every Hawk-owned image and the placement of runners and the janitor. It defaults to arm64 (AWS Graviton); amd64 remains fully supported.

Karpenter always exposes both CPU architectures. The historical default and gvisor NodePools remain amd64 with their names and templates unchanged. The additive default-arm64 and gvisor-arm64 counterparts carry a hawk.metr.org/architecture=arm64:NoSchedule taint. Hawk pins its managed workloads to the selected architecture and adds the ARM toleration when needed, so architecture-unpinned pods remain on x86 without replacing existing NodeClaims.

Eval task and custom runner images

hawk:evalTaskArchitecture controls the default placement of CPU sandbox services and defaults to amd64, independently of cpuArchitecture. Set it to arm64 only when every image in each sandbox, including init containers, supports arm64 or is multi-arch:

pulumi config set hawk:evalTaskArchitecture arm64

Native Kubernetes values.yaml tasks may override kubernetes.io/arch per service. Ordinary Compose tasks cannot currently express that override, so they use evalTaskArchitecture. Hawk preserves explicit native selectors and injects the ARM toleration for an explicitly selected arm64 service. GPU sandbox services remain amd64 because Hawk's NVIDIA NodeClass uses an x86_64 accelerated AMI; an explicit arm64 GPU selector is rejected.

Pods supplied through additionalResources are not rewritten. Unpinned ones remain on x86; an ARM pod there must declare both kubernetes.io/arch: arm64 and a toleration for hawk.metr.org/architecture=arm64:NoSchedule.

Custom runner images must be multi-arch or match cpuArchitecture, because the runner Job itself is pinned to the deployment architecture.

Build host architecture

For native builds, cpuArchitecture should match the machine running pulumi up. A mismatch cross-builds every Hawk image under QEMU emulation, which is very slow and can hang the deploy because there is no per-image build timeout. Use amd64 on an amd64/x86_64 build host, or deploy the default arm64 configuration from an arm64 host. Preflight reports mismatches in either direction.

Quota increases (sizing Path A)

The Quick Start's sizing section offers two paths for fresh AWS accounts; this is the detail for Path A (raising the quotas to fit Hawk's default footprint). Request all three on day one — approval can take days:

# Fargate On-Demand vCPUs (Hawk needs ≥ ~9 for a comfortable default deploy)
aws service-quotas request-service-quota-increase \
  --service-code fargate --quota-code L-3032A538 --desired-value 32 --region <region>
# EC2 Standard On-Demand vCPUs (runner nodes)
aws service-quotas request-service-quota-increase \
  --service-code ec2 --quota-code L-1216C47A --desired-value 32 --region <region>
# Fargate Spot vCPUs (the eval-log importer Batch job needs 8)
aws service-quotas request-service-quota-increase \
  --service-code fargate --quota-code L-36FBB829 --desired-value 32 --region <region>

Brand-new accounts are often denied ("use 90% of your current capacity, then re-request after the next billing cycle"). If that happens, escalate through your AWS account team or AWS Sales contact — they can approve increases that automated support won't — or start with Path B from the Quick Start sizing section in the meantime. The commands may also fail immediately with QuotaExceededException: maximum number of open service quota increase (SQI) requests — brand-new accounts can already be at the open-request cap from AWS's own auto-filed requests; that's fine, proceed on Path B and re-request once the pre-existing requests resolve.

Sizing beyond the defaults

The --desired-value 32 above is a try-out number. For real workloads, the quota that scales with usage is EC2 Standard On-Demand — each concurrent eval consumes a runner node plus its sandbox pods (typically 4-8 vCPUs together), on top of a ~6-vCPU cluster baseline. Fargate does not scale with eval count (it runs only the fixed services and importer jobs), so ~32 Fargate vCPUs is enough for any deployment size; a high Fargate quota simply stops it from ever being the constraint.

Rough tiers to anchor a request:

Tier Fargate OD / Spot EC2 Standard On-Demand G/VT On-Demand (GPU)
Try-out (Path B) 4 (default) 8 (default) 0 — set hawk:enableGpuOperator: "false"
Small team (~5 concurrent evals) 32 64 64 if you run GPU tasks
Production-scale eval org 1,000+ 1,000-6,000 256-768

AWS approves increases step-wise: request 2-4× your current need, generate usage, and re-request as you grow — jumping straight from 8 to thousands will be denied. The production-scale column is what a mature account with billing history can reach, usually via the account-team route rather than the self-service console.

Runner memory

Every eval set gets one runner pod, and hawk:runnerMemory is its memory limit. Kubernetes copies a limit into the request when no request is given, so by default each runner reserves its full limit on a node whether or not it uses it. Raising the limit to cover the occasional heavy eval therefore shrinks how many runners fit on a node, and node count is what consumes the EC2 Standard On-Demand quota above.

hawk:runnerMemoryRequest decouples the two. Set it below runnerMemory and the scheduler reserves only that much, while runners can still grow into the limit:

hawk:runnerMemory: "64Gi"        # ceiling a runner may reach
hawk:runnerMemoryRequest: "8Gi"  # what the scheduler sets aside per runner

An eval set that sets runner.memory itself is scheduled against its value, not the deployment request — a job that names its memory has stated what it needs, and reserving less would place it on a node too small to hold it. The deployment request only applies to eval sets that express no preference.

Pick the request from observed runner memory use, not from the limit. Runners are mostly coordination processes, so typical peak usage is far below the limit that the rare heavy eval set needs. Leave real headroom above the typical peak: this is deliberate overcommit, and if enough runners grow at once the node runs out of memory and the kernel may kill pods that were behaving. Eval sets with genuinely large runners should set runner.memory so they are scheduled honestly.

GPU quotas

If your evals request GPUs, you also need the G and VT instance quota — its fresh-account default is 0, and Hawk's default GPU NodePool uses on-demand capacity:

# G/VT On-Demand vCPUs (g4dn GPU nodes; a g4dn.xlarge is 4 vCPUs)
aws service-quotas request-service-quota-increase \
  --service-code ec2 --quota-code L-DB2E81BA --desired-value 64 --region <region>

GPU quota increases are the slowest and most-denied category — request them well before you need them, and expect anything beyond ~100 vCPUs to require your AWS account team. Note that with the quota at 0, a task requesting a GPU doesn't fail — Karpenter retries provisioning silently forever and the pod stays Pending; if you don't run GPU tasks, set hawk:enableGpuOperator: "false" and skip this entirely.

Infrastructure Options

Config Key Default Description
hawk:eksK8sVersion 1.35 Kubernetes version for EKS
hawk:eksPublicEndpoint true Set to false to make the EKS API endpoint private (requires Tailscale or VPN to reach kubectl)
hawk:albIdleTimeout 3600 ALB idle timeout in seconds
hawk:albInternal false Set to true to make the ALB internal (requires VPN)
hawk:cloudwatchLogsRetentionDays 14 CloudWatch log retention
hawk:autoExcludeEksZones false (true in Pulumi.example.yaml) Auto-exclude the region's known EKS-unsupported AZs when EKS is enabled. Required for a new stack in us-east-1 / us-west-1 / ca-central-1 — without it the deploy fails at apply with UnsupportedAvailabilityZoneException. The code default is false only so that upgrading an existing stack never reshuffles its AZ set.
hawk:excludeZoneIds [] AZ zone IDs (e.g. use1-az3) to exclude from VPC/EKS subnets. Merged with the auto-excluded zones when autoExcludeEksZones is true (it does not replace them); used on its own when it's false.
hawk:enableHawkApi true Deploy the Hawk API and its related services. Requires createRds.
hawk:enableMiddleman true Deploy Middleman. Requires enableHawkApi.
hawk:createRds true Provision Aurora PostgreSQL. Set to false only when API and Middleman are disabled.
hawk:enableGvisor false Install the gvisor RuntimeClass and a dedicated AL2023 Karpenter node class.
hawk:basePythonImage dhi.io/python:3.13-dev Full Python base-image reference for the runner image build.
hawk:cpuArchitecture arm64 CPU architecture for every Hawk image and the placement of Hawk runners and the janitor. arm64 uses the additive Graviton pools; set amd64 to keep Hawk on x86. Must match the architecture of the host running pulumi up, or every image cross-builds under QEMU. See CPU architecture.
hawk:evalTaskArchitecture amd64 Default architecture for CPU eval sandbox services. GPU services remain amd64; native values.yaml tasks may override it per service, while Compose tasks use this default. Set arm64 only when those images support it or are multi-arch.
hawk:ciliumExclusive false Use Cilium's VXLAN overlay and retire aws-node. The default retains AWS VPC CNI chaining.
hawk:ciliumPodCidr 172.16.0.0/12 IPv4 Cilium pod pool in exclusive mode; it must not overlap hawk:primarySubnetCidr.
hawk:dbMinCapacity 0 Aurora Serverless v2 minimum ACUs (0 lets the cluster auto-pause)
hawk:dbMaxCapacity 8 Aurora Serverless v2 maximum ACUs
hawk:protectResources true (non-dev), false (dev envs) When true, S3 buckets and secrets are protected from accidental deletion (Pulumi protect + force_destroy=false). Unset defaults to protected except on dev envs; set explicitly to override.
hawk:runnerMemory 16Gi (app default) Per-runner-pod memory limit. Raise for large eval sets.
hawk:runnerMemoryRequest unset (request = limit) Per-runner-pod memory request (what the scheduler reserves). Set below runnerMemory to overcommit: runners schedule against this but can burst to the limit. Ignored for eval sets that set runner.memory themselves. Clamped to the effective limit. See Runner memory.
hawk:scanImporterMemorySize 3008 Scan importer Lambda memory in MB
hawk:scanImporterEphemeralStorageSize 3008 Scan importer Lambda ephemeral storage in MB
hawk:enableS3ObjectLambda false Create S3 Object Lambda Access Points for permission-filtered eval log reads
hawk:publicModelsOnly false Lock the deployment to public-group model data only — token broker forces model-access-public, OLAP restricts to public groups, RLS bypass is removed from non-admin DB users
hawk:highAvailability false Run 3 replicas of stateless services (API, Middleman) across AZs instead of 1. Middleman HA requires Valkey (valkeyEnabled or valkeyUrl).
hawk:relayEnabled true Deploy the hawk-relay ECS service that backs hawk acp / hawk attach (operator attach to a running eval's pod). Set false to skip its Fargate task + image build if you never attach.
hawk:requireJobToken false Enforce (true) that the token broker reject requests whose projected K8s ServiceAccount identity token doesn't match the requested job (403). Permissive (false, default) logs identity mismatches as an audit warning but still issues credentials — enable only after the audit logs are clean.
hawk:runnerStorageGrants (unset) Named storage grants an eval-set may request via runner.storage_grants: [<name>], giving its runner (never the sandbox) read-only access to the grant's S3 bucket at runtime; the token broker attaches one read-only session policy per requested grant after re-checking the submitter's permissions and verified job identity. Each grant sets bucketArn (required), permission (required; validated at pulumi preview and API startup to be a single token, not a model-access-*/OAuth-scope name, and disjoint from the default permissions), mode (only read), optional kmsKeyArn (Decrypt via S3 only; prefer a key dedicated to the bucket), and optional env injected into opted-in runners. Unset ⇒ feature off. Grant problems never cost a runner its base credentials: a grant that is unknown to the broker (e.g. removed from the config while runs using it were live), unverified, or unauthorized is dropped from the session with an error log and a StorageGrantUnknown/StorageGrantsDenied metric, so the run continues but loses access to that bucket mid-flight — prefer draining runs that requested a grant before removing it.

hawk:ciliumExclusive is for new or deliberately migrated clusters. With the default value, the existing AWS VPC CNI chaining behavior and default 10.0.0.0/8 Cilium pod IP pool are unchanged. Exclusive mode uses the configured RFC 1918 hawk:ciliumPodCidr pool and VXLAN encapsulation instead.

EKS-unsupported availability zones

Some AZs don't support the EKS control plane, and a cluster that lands on one fails with UnsupportedAvailabilityZoneException: Cannot create cluster ... in us-east-1e .... Setting hawk:autoExcludeEksZones: "true" makes Hawk drop the region's known-bad AZs (use1-az3/us-east-1, usw1-az2/us-west-1, cac1-az3/ca-central-1) before it picks subnets, so a new deployment in an affected region needs no further action:

config:
  hawk:autoExcludeEksZones: "true"

Pulumi.example.yaml ships with it on, and in these three regions a new stack needs it — every private subnet becomes an EKS control-plane subnet, so leaving it off puts one in the unsupported AZ and the apply fails. The code default is false only so that upgrading Hawk can't change a deployed stack's AZ set behind your back (see the upgrade warning below). If you write your stack config from this table rather than copying the example, set it explicitly.

Auto-exclusion only kicks in when EKS is being created (hawk:createEks, the default); a VPC-only stack keeps its full AZ layout either way. Note the corollary: on an opted-in stack in an affected region, later toggling hawk:createEks changes the AZ set too, with the same replacement consequences.

Use hawk:excludeZoneIds to exclude additional zones — a newly-unsupported AZ that isn't in Hawk's list yet, or one you're avoiding for capacity reasons. Entries are merged with the auto-excluded ones (they don't replace them), so a known-bad zone stays excluded whether or not you list it:

config:
  hawk:excludeZoneIds:
    - use1-az5

Verify against the live AWS error message — supported AZs change over time.

Editing EKS_UNSUPPORTED_ZONE_IDS changes deployed stacks

The opt-in flag protects the transition, not the contents. Once a stack sets autoExcludeEksZones: "true", its AZ set is a function of the hardcoded table in infra/lib/eks_zones.py — so any edit to that table moves the AZ set of every opted-in stack in the affected region on the next routine pulumi up, with the same destructive subnet replacement described below. Removing an entry (because AWS added support for a zone) is just as disruptive as adding one: the AZ comes back, takes its old position, and everything after it renumbers.

Treat the table as append-only in practice. If AWS adds support for a listed zone, the safe migration for existing stacks is to leave the table alone and let them keep the AZ set they have; only remove the entry if you're prepared to do the staged migration below on every affected stack.

Turning autoExcludeEksZones on for an existing stack is a migration, not a config change

Enabling it on a deployed stack in us-east-1 / us-west-1 / ca-central-1 forces destructive subnet replacement, and pulumi up will most likely fail partway rather than complete. Subnet CIDRs are assigned by AZ position (infra/core/vpc.py) while subnet resources are keyed by AZ name, so dropping an AZ leaves each surviving later AZ pointing at its neighbour's CIDR. Pulumi creates replacements before deleting the originals, so:

  • the new subnet collides with the live one still holding that CIDR (InvalidSubnet.Conflict); and
  • the old subnets can't be deleted anyway while ALB, EKS/Karpenter, RDS and VPC-endpoint ENIs reference them (DependencyViolation).

If the excluded zone happens to sort first in your account, the NAT gateway's subnet changes too and it is replaced along with them.

pulumi preview shows the replacements but not either of those failures, so a clean preview is not evidence the apply will succeed. Measured on a fresh us-east-1 stack (sandbox account, where use1-az3 is us-east-1e and sorts 5th of 6): 440 resources with the flag off vs 435 with it on — the delta is 2 subnets, 1 route table and 2 associations, all in us-east-1e, with us-east-1f renumbered from 10.0.80.0/20 to 10.0.64.0/20. Where the bad zone sorts in your account determines how much moves.

What to do instead, in order of preference:

  1. Leave the flag unset. A working cluster already avoids the bad AZ, so there's nothing to gain. This is the supported steady state, not a deprecated one.
  2. Pin the AZ set explicitly with hawk:excludeZoneIds if you want it recorded in config rather than implied. Matching your current AZ set produces no diff.
  3. Rebuild the VPC if you genuinely need the AZ set to change (e.g. the stack's EKS creation previously failed because its VPC spans the unsupported AZ). Plan it as a staged migration with downtime, not an in-place pulumi up.

Fresh deployments need none of this — nothing exists to replace.

Minimum of two availability zones

Hawk refuses to plan a stack whose exclusions leave fewer than two AZs, failing with a clear error instead of letting pulumi preview succeed and the apply die on the ALB, the EKS control plane or the RDS subnet group.

This is most likely in us-west-1, where AWS exposes only two of the three physical zones to any given account and which two varies: an account mapped to usw1-az1 + usw1-az2 loses half its zones to the auto-exclusion default. If you hit it, drop entries from hawk:excludeZoneIds, set hawk:autoExcludeEksZones to false, or deploy to a region where your account sees more zones.

Authentication (advanced)

The three keys in the Authentication section above are the minimum for an external OIDC provider. These additional keys override defaults that are otherwise discovered from the issuer:

Config Key Default Description
hawk:oidcScopes openid profile email offline_access Scopes requested at login. Space-separated.
hawk:oidcEmailField email JWT claim used to extract the user's email.
hawk:oidcJwksUri discovered Override the JWKS URI (rare).
hawk:oidcTokenEndpoint discovered Override the token endpoint.
hawk:oidcAuthorizationEndpoint discovered Override the authorization endpoint.
hawk:oidcDeviceAuthorizationEndpoint discovered Override for the device code flow.
hawk:oidcRevocationEndpoint discovered Override for token revocation.
hawk:defaultPermissions model-access-public Space-separated permissions granted to authenticated users whose tokens carry no permissions / scp / cognito:groups claim (Cognito users who aren't in any group). Set to the empty string ("") to grant no default permissions.
hawk:refreshTokenLifetimeDays 90 Refresh-token lifetime in days, mirroring your IdP's refresh-token lifetime. Advertised to the CLI so its pre-submit preflight can tell when a token will expire.
hawk:expectedLongestRunDays 30 Longest a run is expected to stay alive, in days; the CLI forces a re-login before submitting a run whose token wouldn't survive it. Must not exceed hawk:refreshTokenLifetimeDays (the CLI re-login cadence is the difference between the two). Raising it also widens ECR runner-image retention, which is this value plus 7 days.

Run the OIDC autodiscovery script

python scripts/dev/discover-oidc.py <issuer-url> <client-id> <audience> prints the full set of hawk:oidc* config values to paste into your stack file.

Observability

Config Key Default Description
hawk:enableDatadog false Enable Datadog metrics, logs, and APM. See Datadog below.
hawk:datadogSite datadoghq.com Datadog site (e.g. datadoghq.eu, us3.datadoghq.com).
hawk:createDatadogAwsIntegration false When true, Pulumi provisions the Datadog ↔ AWS integration role.
hawk:hawkSentryDsn unset Sentry DSN for the Hawk API + Lambdas.
hawk:middlemanSentryDsn unset Sentry DSN for Middleman.
hawk:otelTracingEnabled false Send OTLP traces from API/Middleman/Lambdas to AWS X-Ray.
hawk:otelSpanSamplingPercentage 100 Sampling rate (0–100) when OTel tracing is enabled.
hawk:stuckEvalWebhookUrl unset HTTPS webhook subscribed to the stuck-eval-set SNS alert topic (prd-only monitor for eval-sets whose samples stop progressing). The same topic also carries the approval-waiting alarm, which fires when an eval-set has a sample blocked on a human tool-call approval for 10+ minutes.

Optional Integrations

These are all disabled by default. Enable them in your stack config when needed.

Datadog

Monitoring, APM, and log forwarding:

hawk:enableDatadog: "true"
hawk:datadogSite: datadoghq.com

Requires a <env>/platform/datadog-api-key secret in AWS Secrets Manager.

Cloudflare

Option C from the Domain & DNS table above, for when your parent domain is in Cloudflare and you want a subdomain delegated to AWS. In both cases hawk:publicDomain must be a subdomain of the Cloudflare parent domain.

There are two mutually exclusive ways to do this — pick one:

Leave hawk:createPublicZone: "false" and do not set the hawk:cloudflare* keys. Create or import the Route 53 public hosted zone first, then create NS records in your Cloudflare parent zone (pointing the subdomain at that zone's four nameservers) before deploy. If the subdomain was delegated before (e.g. an earlier deployment attempt), replace the old NS records — adding new ones alongside stale ones yields a mixed nameserver set and intermittent SERVFAIL. No Cloudflare API token is needed, and scripts/dev/preflight.sh can verify the delegation before pulumi up.

When only this single subdomain is delegated to Route 53, set both hawk:publicDomain and hawk:domain to it (e.g. both hawk.example.com) — service certs validate in the publicDomain zone, so hawk:domain must live inside it; the resulting api.hawk.hawk.example.com-style URLs are cosmetic. See the domain-relationship warning in Domain & DNS above.

Pulumi-managed delegation (incompatible with strict preflight)

Let Pulumi create the Route 53 public zone and manage the Cloudflare NS records for you. This requires hawk:createPublicZone: "true", so the zone's nameservers don't exist until the run — strict preflight will fail the DNS check (see the createPublicZone warning above).

hawk:publicDomain must be a subdomain of hawk:cloudflareParentDomain (Pulumi raises a ValueError otherwise).

hawk:createPublicZone: "true"
hawk:cloudflareZoneId: "your-zone-id"
hawk:cloudflareParentDomain: "example.com"
hawk:publicDomain: "hawk.example.com"

Before pulumi up, create a Cloudflare API token (Zone:DNS:Edit on the parent zone) and store it in AWS Secrets Manager:

aws secretsmanager create-secret \
  --name "<env>/platform/cloudflare-api-token" \
  --secret-string "<token>"

<env> defaults to your Pulumi stack name. The deploy will fail with a "secret not found" error if this isn't set up first.

Tailscale

VPN overlay for private service access:

Set hawk:albInternal: "true" and store a Tailscale auth key in AWS Secrets Manager. This makes all services accessible only through your Tailscale network.

CrowdStrike Falcon

Endpoint protection for EKS nodes and the Tailscale subnet router:

hawk:enableCrowdstrike: "true"

Requires a <env>/platform/crowdstrike secret in AWS Secrets Manager with:

{
  "cid": "YOUR-CUSTOMER-ID",
  "client_id": "YOUR-API-CLIENT-ID",
  "client_secret": "YOUR-API-CLIENT-SECRET",
  "base_url": "https://api.us-2.crowdstrike.com"
}

Setup:

  1. In the CrowdStrike Falcon console, go to Support and resources > API clients and keys and create a client with Sensor Download: Read scope.
  2. Copy your CID from Host setup and management > Deploy > Sensor downloads.
  3. Your base_url matches your Falcon console URL (e.g. falcon.us-2.crowdstrike.comhttps://api.us-2.crowdstrike.com).

When enabled, this installs the Falcon sensor on:

  • All EKS nodes (Bottlerocket and AL2023) — via the falcon-sensor DaemonSet, which requires the Falcon Images Download API scope, part of the Falcon Cloud Security with Containers add-on.
  • Tailscale subnet router (AL2023 ARM64) — via cloud-init at instance boot, since it has no DaemonSet to rely on.

EKS nodes get the sensor from the DaemonSet alone. Do not add a host-RPM install to a node pool's userData as well: a node cannot run both, and the containerized sensor will sit in CrashLoopBackOff.

Budget Alerts

hawk:budgetLimit: "10000"
hawk:budgetNotificationEmails:
  - "team@example.com"

When integrations are disabled, services fall back to simpler alternatives (CloudWatch instead of Datadog, no DNS delegation, etc.).