A working reference for one agency's single deployment: the full configuration surface, the container, the deploy command, and the permissions that keep it locked down. Everything here is illustrative — the shape of a real setup, not a copy-paste product. Placeholder values only; no real secrets appear, and none should ever appear in a file you commit.
The framing throughout is single-tenant: one container, one set of secrets, one agency's ATS credentials, one audit store. There is no "across clients" here. There is your deployment, and that's the whole picture.
.env.exampleThis is the complete configuration surface for the three agents. Every key is documented. On your laptop these load through DotNetEnv; in production not one of them lives in a file — they arrive from a managed secret store through the same IConfiguration pipeline, so the application code barely notices the difference.
.env.exampleis committed..envis not. Keep.gitignorehonest, and run a pre-commit secret scanner so a real key can't reach the repo in the first place.
# =============================================================================
# .env.example — copy to .env for local dev only. NEVER commit a real .env.
# In production these are mounted from GCP Secret Manager (AWS Secrets Manager
# / Azure Key Vault), not read from disk. See B.4.
# =============================================================================
# --- Runtime ----------------------------------------------------------------
ASPNETCORE_ENVIRONMENT=Development # Development | Production
PORT=8080 # Cloud Run injects this; honour it
LOG_LEVEL=Information # Trace|Debug|Information|Warning|Error
# --- LLM provider -----------------------------------------------------------
OPENAI_API_KEY=sk-REPLACE_ME # secret. rotate on a schedule
OPENAI_MODEL=gpt-4o # the screening/shortlisting model
OPENAI_MODEL_FALLBACK=gpt-4o-mini # cheaper model for cheap steps
OPENAI_REQUEST_TIMEOUT_SECONDS=60 # fail fast; Polly handles retry
LLM_MAX_TOKENS_PER_CALL=4000 # hard ceiling per call (cost guard)
# --- Bullhorn (flagship ATS) ------------------------------------------------
# OAuth2; see Appendix C for the auth flow and endpoints.
BULLHORN_CLIENT_ID=REPLACE_ME # secret
BULLHORN_CLIENT_SECRET=REPLACE_ME # secret. never log this
BULLHORN_USERNAME=REPLACE_ME # API user, least-privilege role
BULLHORN_PASSWORD=REPLACE_ME # secret
BULLHORN_DATACENTER_REST_URL=https://rest.bullhornstaffing.com # region-specific
# --- JobAdder (second ATS) --------------------------------------------------
# OAuth2 auth-code; see Appendix C for the auth flow and endpoints.
JOBADDER_CLIENT_ID=REPLACE_ME # secret
JOBADDER_CLIENT_SECRET=REPLACE_ME # secret. never log this
JOBADDER_REDIRECT_URI=https://your-host/oauth/jobadder/callback
JOBADDER_ACCESS_TOKEN=REPLACE_ME # secret. ~60-min lifetime
JOBADDER_REFRESH_TOKEN=REPLACE_ME # secret. ROTATES — persist the new
# one on every refresh
JOBADDER_API_BASE_URL=https://api.jobadder.com/v2 # the token response returns
# the per-account 'api' URL; store
# THAT and prefix calls with it
# --- Secrets & cloud (production) -------------------------------------------
GCP_PROJECT_ID=your-gcp-project # AWS: account/region; Azure: vault
SECRET_STORE=gcp # gcp | aws | azure | env(dev only)
# --- Guardrail gateway (the enforcement layer — see Ch 9) -------------------
DLP_INSPECT_ENABLED=true # fail-closed if the inspector is down
DLP_FAIL_MODE=closed # closed = block on doubt. never 'open'
ALLOWLIST_PROFILE=screening # which structured-field allowlist
PII_REDACTION_REQUIRED=true # formatting agent must strip PII
# --- Observability (cloud-neutral; see Ch 11) -------------------------------
OTEL_EXPORTER_OTLP_ENDPOINT= # blank = use cloud default exporter
OTEL_SERVICE_NAME=recruitment-agents
SERILOG_MINIMUM_LEVEL=Information
SAFE_SINK_REFUSE_RAW_PAYLOADS=true # logger refuses raw CV/PII payloads
# --- Queue / DLQ (durable work; see Ch 10) ----------------------------------
PUBSUB_TOPIC=cv-intake # AWS: SQS+SNS; Azure: Service Bus
PUBSUB_DLQ_TOPIC=cv-intake-dead # poison messages land here
PUBSUB_MAX_DELIVERY_ATTEMPTS=5 # then route to DLQ
# --- Audit store ------------------------------------------------------------
AUDIT_STORE_CONNECTION=REPLACE_ME # secret. append-only decision log
AUDIT_RETENTION_DAYS=365 # align to your GDPR retention policyA note on what is not here: there is no MULTI_TENANT, no per-client key, no client routing. One deployment, one tenant. If you ever feel the urge to add a "client ID" column, that's a second product — and a different book.
Container-first and cloud-agnostic by design: the same image runs on Cloud Run, App Runner / ECS Fargate, or Azure Container Apps. Multi-stage build keeps the final image small and free of the SDK and source.
# ---- build stage -----------------------------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.sln .
COPY src/ ./src/
RUN dotnet restore
RUN dotnet publish src/RecruitmentAgents.Api -c Release -o /app \
--no-restore /p:PublishTrimmed=false
# ---- runtime stage ---------------------------------------------------------
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app .
# Run as a non-root user — least privilege at the process level too.
RUN adduser --disabled-password --gecos "" appuser
USER appuser
# Cloud Run sets PORT; bind to it. Stateless: durable work lives in the queue.
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "RecruitmentAgents.Api.dll"]No secrets in the image. No
.envcopied in. A secret baked into a layer is a secret leaked forever — layers are cached, pushed, and pulled.
The service is stateless on purpose. Scale-to-zero means an idle instance gets reclaimed; if in-flight work lived in memory, it would vanish with the instance. It doesn't — durable work lives in the queue and the ATS, so a reclaimed instance loses nothing. (the reliability chapter covers the cold-start and warming trade-offs that come with this.)
The canonical runtime is Cloud Run (AWS App Runner / ECS Fargate / Azure Container Apps), chosen because it scales to zero — when no CVs are arriving, you're running nothing and paying for nothing.
# 1. Build and push the image to Artifact Registry
gcloud builds submit \
--tag europe-west2-docker.pkg.dev/$GCP_PROJECT_ID/agents/recruitment:latest
# 2. Store each secret once (do this from a trusted machine, not CI logs)
printf '%s' "$BULLHORN_CLIENT_SECRET" | \
gcloud secrets create bullhorn-client-secret --data-file=-
# ...repeat for openai-api-key, jobadder-client-secret, audit-store-connection, etc.
# 3. Deploy, mounting secrets at runtime (NOT baked into the image)
gcloud run deploy recruitment-agents \
--image europe-west2-docker.pkg.dev/$GCP_PROJECT_ID/agents/recruitment:latest \
--region europe-west2 \
--no-allow-unauthenticated \
--service-account agents-runtime@$GCP_PROJECT_ID.iam.gserviceaccount.com \
--min-instances 0 --max-instances 5 \
--cpu 1 --memory 1Gi --timeout 300 \
--set-env-vars ASPNETCORE_ENVIRONMENT=Production,SECRET_STORE=gcp \
--set-secrets \
OPENAI_API_KEY=openai-api-key:latest,\
BULLHORN_CLIENT_SECRET=bullhorn-client-secret:latest,\
JOBADDER_CLIENT_SECRET=jobadder-client-secret:latest,\
AUDIT_STORE_CONNECTION=audit-store-connection:latestThe load-bearing flags:
--no-allow-unauthenticated — the service is private. It is invoked by Pub/Sub or Cloud Scheduler with an authenticated identity, never exposed to the open internet.--service-account — the service runs as its own identity, not the project default. This is what makes least-privilege IAM possible.--set-secrets — secrets are mounted at runtime from Secret Manager. They are never passed as plaintext env vars and never appear in a build layer.--min-instances 0 — scale to zero. Cold starts are the cost of this.The shape is identical; only the nouns change.
| Step | GCP | AWS | Azure |
|---|---|---|---|
| Image registry | Artifact Registry | ECR | Azure Container Registry |
| Runtime | Cloud Run | App Runner / ECS Fargate | Container Apps |
| Secret store | Secret Manager | Secrets Manager | Key Vault |
| Secret mount | --set-secrets | task-def secrets: from Secrets Manager | Key Vault reference / CSI driver |
| Runtime identity | service account | IAM task role | managed identity |
| Private invoke | --no-allow-unauthenticated + IAM | private ALB / IAM auth | internal ingress + RBAC |
Download the full PDF for free?