Datadog as Code with Terraform
The idea in one sentence
Section titled “The idea in one sentence”The official Terraform Datadog provider lets you define monitors, dashboards, SLOs, synthetics, log pipelines, and even who is allowed to change any of that, as versioned, reviewable code, instead of clicking them together by hand in the UI, one environment at a time.
Why bother managing Datadog as code
Section titled “Why bother managing Datadog as code”Clicking a monitor together in the UI is fast the first time. The trouble shows up the second time: you need the same monitor in staging, the same dashboard in a new region, or you need to know exactly what changed to a threshold last month and why. UI-built configuration has no diff, no review step, and no single source of truth — two people can quietly disagree about what a monitor’s threshold “should” be, and nothing catches it.
Managing monitors, dashboards, SLOs, and more through the Terraform Datadog provider turns all of that into a code change: it lives in a repository, it gets reviewed in a pull request like any other change to production, and the same module can be instantiated for prod, staging, and every new service that comes along by changing a variable instead of re-clicking a wizard.
A real datadog_monitor resource, with recovery thresholds and notify_no_data
Section titled “A real datadog_monitor resource, with recovery thresholds and notify_no_data”Here is a metric alert monitor expressed as Terraform:
resource "datadog_monitor" "high_cpu" { name = "High CPU on checkout-service" type = "metric alert" message = "CPU is high on checkout-service. Notify: @slack-sre-alerts"
query = "avg(last_5m):avg:system.cpu.user{service:checkout-service} by {host} > 85"
monitor_thresholds { warning = 70 warning_recovery = 65 critical = 85 critical_recovery = 80 }
notify_no_data = true no_data_timeframe = 20
tags = ["service:checkout-service", "team:checkout"]}type—metric alertis one of several monitor types (others includelog alert,apm alert, and thecost alerttype covered in the next lesson).message— the notification body. The@slack-sre-alertshandle is a Datadog notification target, resolved the same way it would be if you typed it into the UI’s message box.query— the actual alerting condition, evaluated asavg(last_5m):avg:system.cpu.user{service:checkout-service} by {host} > 85.monitor_thresholds.critical/.warning— the ordinary trigger thresholds you’d otherwise drag in the UI.monitor_thresholds.critical_recovery/.warning_recovery— a separate, lower bar the metric has to cross back under before the monitor clears the alert state. Confirmed against the real provider schema (terraform providers schema -json,datadog_monitor.monitor_thresholdsblock):critical_recoveryandwarning_recoveryare real, distinct attributes fromcritical/warning, not something you infer from a single threshold.notify_no_data/no_data_timeframe— whether the monitor should alert when its query stops returning data at all (as opposed to returning data that’s below threshold), and how many minutes of silence to tolerate first. Also confirmed as real top-level attributes on the same schema.tags— ordinary Datadog tags on the monitor object itself, useful for filtering the Manage Monitors page and for routing via Service Catalog ownership.
Why the recovery threshold matters, concretely: without critical_recovery, a monitor with critical = 85 flips to Alert the instant CPU crosses 85% and flips back to OK the instant it dips to 84.9% — if CPU is oscillating around 85, that’s a monitor flapping between Alert and OK every evaluation cycle, and a notification every time it flips. Setting critical_recovery = 80 means CPU has to drop meaningfully below the trigger point, not just barely under it, before the monitor clears — the gap between 85 and 80 is a deliberate hysteresis band.
The important caveat, unchanged from before: the query string in a datadog_monitor resource is not the same syntax you’d type into the Datadog UI’s monitor query builder. The UI builder is a guided widget that assembles a query for you; the Terraform query field is the raw API query syntax, and it has to be written by hand, correctly, the first time. By default, terraform plan sends that query to Datadog’s API for validation, so a malformed query fails at plan time rather than silently creating a broken monitor. If you genuinely need to skip that check — for example, referencing a metric that doesn’t exist yet in this environment — you can set validate = false on the resource, but that trades away your safety net, so it should be the exception, not the default.
Dashboards as code: the real widget query shape
Section titled “Dashboards as code: the real widget query shape”The dashboard lesson from the Infrastructure & Metrics module covered named queries, formulas, and template variables from the UI’s point of view. The same widget model shows up in Terraform via datadog_dashboard, and getting the request block’s shape right is where an AI-generated example most commonly breaks.
With layout_type = "ordered", widgets simply stack top to bottom in the order you declare them — the simplest option, and a good default. Here is the correct, validated shape:
resource "datadog_dashboard" "checkout_overview" { title = "Checkout Service Overview" layout_type = "ordered"
widget { timeseries_definition { request { query { metric_query { name = "query1" data_source = "metrics" query = "avg:system.cpu.user{service:checkout-service} by {host}" } } formula { formula_expression = "query1" } display_type = "line" } } }}With layout_type = "free", every widget needs its own widget_layout block, giving you exact pixel-grid placement with x, y, width, and height:
resource "datadog_dashboard" "checkout_overview_free" { title = "Checkout Service Overview (free layout)" layout_type = "free"
widget { timeseries_definition { request { query { metric_query { name = "query1" data_source = "metrics" query = "avg:system.cpu.user{service:checkout-service} by {host}" } } formula { formula_expression = "query1" } display_type = "line" } } widget_layout { x = 0 y = 0 width = 4 height = 2 } }}Both of the above: terraform validate → Success! The configuration is valid. The shape is: request → query block → metric_query block (where name/data_source/query actually live) → sibling formula block (formula_expression referencing the query’s name) → display_type back at the request level, not inside query. This is the same request/query/formula split you’d see in the JSON dashboard API’s formulas/queries pair — Terraform just nests it one block deeper.
ordered is the right default for most operational dashboards — you rarely need to fight pixel positions for a dashboard that mostly gets scrolled top to bottom. free earns its keep for TV-wall dashboards or intentionally designed layouts where widgets need to sit at specific coordinates relative to each other.
datadog_dashboard vs. datadog_dashboard_json
Section titled “datadog_dashboard vs. datadog_dashboard_json”There’s a second way to manage a dashboard in Terraform: datadog_dashboard_json takes the dashboard’s entire body as one JSON string via jsonencode(...), instead of a widget-by-widget HCL tree:
resource "datadog_dashboard_json" "checkout_from_json" { dashboard = jsonencode({ title = "Checkout Service Overview (JSON)" layout_type = "ordered" widgets = [ { definition = { type = "timeseries" requests = [ { queries = [ { name = "query1" data_source = "metrics" query = "avg:system.cpu.user{service:checkout-service} by {host}" } ] formulas = [{ formula = "query1" }] display_type = "line" } ] } } ] })}Validated the same way — terraform validate passes. Notice this JSON body uses the flat shape (name/data_source/query as siblings inside one queries[] object) — that’s correct here, because this is the real Dashboards JSON API’s own payload shape, not the HCL block tree. The bug earlier in this lesson was using the JSON API’s flat shape inside HCL’s block syntax; datadog_dashboard_json is where that flat shape actually belongs.
datadog_dashboard— every widget and query is a typed HCL block. Terraform’s own schema validates the structure (this is what caught the bug above atterraform validatetime, before ever touching a real API). Diffs in a pull request are structural: “widget 3’sdisplay_typechanged fromlinetobars” reads clearly.datadog_dashboard_json— one opaque JSON blob. You lose per-field schema validation (a typo inside the JSON string is only caught by the API at apply time, or not caught by Terraform at all —jsonencodeonly guarantees valid JSON syntax, not a valid dashboard payload), but you gain the ability to paste a dashboard exported from the UI (Dashboard settings → Export dashboard JSON) directly in, without hand-translating every widget into HCL blocks.
The trade-off in practice: hand-authored new dashboards are easier to write correctly and review as datadog_dashboard; a dashboard someone already built by clicking around the UI and now wants under version control is far faster to bring in as datadog_dashboard_json.
flowchart LR
A[Write datadog_monitor / datadog_dashboard in HCL] --> B[terraform plan]
B --> C{Query validation}
C -->|valid, or validate = false| D[Pull request review]
C -->|invalid| E[Plan fails: fix query]
D --> F[terraform apply]
F --> G[Monitor / Dashboard / SLO created in Datadog]
G --> H[Reused across prod, staging, new services via variables] datadog_service_level_objective
Section titled “datadog_service_level_objective”The SLOs lesson (Monitors, Alerting, and SLOs module) covered the three SLO types and the real burn_rate()/error_budget() query shapes as raw JSON. The same metric-based SLO as Terraform:
resource "datadog_service_level_objective" "checkout_availability" { name = "Checkout API availability" type = "metric" description = "Ratio of non-5xx checkout-api requests"
query { numerator = "sum:trace.http.request.hits{service:checkout-api,!http.status_code:5*}.as_count()" denominator = "sum:trace.http.request.hits{service:checkout-api}.as_count()" }
thresholds { timeframe = "30d" target = 99.9 }
tags = ["service:checkout-api", "team:checkout"]}terraform validate passes. The block shape mirrors the JSON payload closely — query.numerator/query.denominator and a thresholds block with timeframe/target — the main difference from clicking the SLO editor in the UI is that both the SLO and its burn-rate/error-budget alert monitors (from the earlier lesson) can now live in the same reviewed pull request.
datadog_synthetics_test, briefly
Section titled “datadog_synthetics_test, briefly”The Synthetics & Uptime Checks lesson already covers datadog_synthetics_test in depth, including a real terraform validate error for an invalid type. The as-code point worth repeating here: a synthetic test is just another resource in the same file as your monitors and dashboards, so “checkout-api has a monitor, a dashboard, and an uptime check” can be one reviewed change instead of three separate manual steps across three different UI screens:
resource "datadog_synthetics_test" "checkout_api_up" { name = "checkout-api uptime" type = "api" subtype = "http" status = "live" message = "@pagerduty-checkout-critical checkout-api synthetic failed" locations = ["aws:eu-central-1"]
request_definition { method = "GET" url = "https://checkout.example.com/health" }
assertion { type = "statusCode" operator = "is" target = "200" }
options_list { tick_every = 60 }}terraform validate passes against the pinned provider.
Log pipelines and indexes as code
Section titled “Log pipelines and indexes as code”The Log Management module covered Processing Pipelines and indexes from the UI’s point of view. Both are ordinary Terraform resources too.
resource "datadog_logs_custom_pipeline" "checkout_pipeline" { name = "checkout-api pipeline" is_enabled = true
filter { query = "service:checkout-api" }
processor { grok_parser { name = "Parse checkout-api log line" is_enabled = true source = "message" grok { match_rules = "rule %%{date(\"yyyy-MM-dd'T'HH:mm:ss\"):timestamp} %%{word:log_level} %%{data:msg}" support_rules = "" } } }
processor { date_remapper { name = "Define timestamp as the official date" is_enabled = true sources = ["timestamp"] } }
processor { status_remapper { name = "Define log_level as the official status" is_enabled = true sources = ["log_level"] } }}resource "datadog_logs_index" "checkout_index" { name = "checkout-api"
filter { query = "service:checkout-api" }
exclusion_filter { name = "drop-debug-logs" is_enabled = true filter { query = "status:debug" sample_rate = 0 } }
daily_limit = 5000000 retention_days = 15}
resource "datadog_logs_index_order" "index_order" { name = "checkout index order" indexes = [ datadog_logs_index.checkout_index.id, "main", ]}terraform validate passes for both. datadog_logs_index_order matters for a reason that’s easy to miss: log indexes are evaluated in order, and the first index whose filter.query matches a log claims it — datadog_logs_index_order.indexes is the literal, explicit ordering, referencing the new index’s id alongside the pre-existing "main" index by name. Get the order wrong and a narrower, purpose-built index like checkout-api can silently never receive a single log, because the broader main index (if listed first) claims everything before checkout-api’s filter is ever evaluated.
datadog_monitor_json
Section titled “datadog_monitor_json”Same relationship as datadog_dashboard vs. datadog_dashboard_json — datadog_monitor_json takes the whole monitor body as one JSON string:
resource "datadog_monitor_json" "high_cpu_json" { monitor = jsonencode({ name = "High CPU on checkout-service (JSON)" type = "metric alert" query = "avg(last_5m):avg:system.cpu.user{service:checkout-service} by {host} > 85" message = "CPU is high on checkout-service." tags = ["service:checkout-service"] options = { thresholds = { critical = 85 warning = 70 } notify_no_data = true } })}terraform validate passes. Same trade-off as the dashboard pair: datadog_monitor gives you schema-validated HCL blocks; datadog_monitor_json is faster for pasting in a monitor exported from the UI, at the cost of losing Terraform’s own structural validation on that JSON body.
Importing an existing monitor
Section titled “Importing an existing monitor”Every resource so far assumed you’re creating something new. Datadog resources built by hand in the UI before you adopted Terraform don’t have to be recreated — they can be imported into Terraform state so Terraform starts managing something that already exists.
$ terraform import datadog_monitor.bytes_received_localhost 2081This is the real import syntax from the provider’s own documentation (terraform-provider-datadog/docs/resources/monitor.md): the resource address (datadog_monitor.bytes_received_localhost — must already exist as an empty resource "datadog_monitor" "bytes_received_localhost" {} block in your .tf file) followed by the monitor’s numeric ID from the Datadog UI or API. datadog_monitor_json imports the same way — by the resource’s numeric ID directly, since the provider source (resource_datadog_monitor_json.go) wires it up with schema.ImportStatePassthroughContext, meaning the ID you pass on the command line is used as-is to fetch the monitor.
Importing only populates Terraform state — it does not generate the HCL for you. After terraform import, run terraform plan: any difference between the resource block you wrote and what actually exists in Datadog shows up as a planned change, which is your signal to adjust the HCL to match reality (or accept the plan and let Terraform reconcile the two) before this resource is safely under code review.
Provider authentication, per Datadog site
Section titled “Provider authentication, per Datadog site”Every example above assumes a provider "datadog" block already exists. In practice, this is where the fake credentials in this lesson’s own validation setup come from:
# This IS the provider block -- not collected by the harness's --tf mode# (which generates its own providers.tf per fence and would conflict with# a second one), validated standalone in scratch with the exact fake# all-zeros key/app-key this lesson uses everywhere else: terraform init +# terraform validate -> Success.terraform { required_providers { datadog = { source = "DataDog/datadog" version = "4.22.0" } }}
provider "datadog" { api_key = "<your-api-key>" app_key = "<your-app-key>" api_url = "https://api.datadoghq.eu/"}api_key/app_key— required unlessvalidate = false. Can also be set via theDD_API_KEY/DD_APP_KEYenvironment variables instead of hardcoding them in HCL (confirmed against the provider’s own schema documentation) — the standard pattern for keeping real credentials out of a Terraform file that gets committed to a repository.api_url— not controlled byDD_SITE. The provider docs are explicit about this:api_url“can also be set via theDD_HOSTenvironment variable” (notDD_SITE), defaults tohttps://api.datadoghq.com, and must not end in/api/. If your org is on the EU site,https://api.datadoghq.eu/; other regions follow the same pattern (https://api.us5.datadoghq.com/,https://api.us3.datadoghq.com/,https://api.ddog-gov.com/— seedocs.datadoghq.com/getting_started/site/for the full list). This is a real, easy trap:DD_SITEis the environment variable the Agent and dd-trace/ddtrace tracers read (see the Foundations module), but the Terraform provider reads a differently-named variable for the equivalent setting. SettingDD_SITE=datadoghq.euin your shell and expecting the Terraform provider to pick it up silently does nothing — it keeps talking todatadoghq.comunlessapi_url/DD_HOSTis also set correctly.
This lesson’s own validation never calls the real API at all — validate = false on the provider block, plus all-zeros fake api_key/app_key, is what makes terraform init + terraform validate safe to run with no Datadog account anywhere near it.
A real terraform validate error, on purpose
Section titled “A real terraform validate error, on purpose”Every fence so far was written to be correct. Here’s a deliberately broken one, to show what a real schema violation looks like instead of a hallucinated-sounding one:
# @expect-error terraform validate real error: Unsupported argument — An argument named "notify_no_data_timeframe" is not expected here.resource "datadog_monitor" "high_cpu" { name = "High CPU on checkout-service" type = "metric alert" message = "CPU is high on checkout-service." query = "avg(last_5m):avg:system.cpu.user{service:checkout-service} by {host} > 85"
monitor_thresholds { critical = 85 }
notify_no_data_timeframe = 20}Ran through the same terraform init + terraform validate as every other fence in this lesson:
Error: Unsupported argument on main.tf line 11, in resource "datadog_monitor" "high_cpu": 11: notify_no_data_timeframe = 20An argument named "notify_no_data_timeframe" is not expected here.There is no such attribute — the real names, confirmed against the schema earlier in this lesson, are the separate notify_no_data (boolean) and no_data_timeframe (number). notify_no_data_timeframe is the kind of name an AI model or a tired human might invent by blending the two real ones together, and Terraform’s own schema validation catches it immediately, before apply ever runs.
State and secrets: the app key ends up in state
Section titled “State and secrets: the app key ends up in state”A datadog_role or a monitor referencing sensitive query values isn’t usually the risk — the Terraform state file itself is. Every attribute Terraform reads back from Datadog’s API, including anything the provider itself considers sensitive, gets written into terraform.tfstate in plain text by default. The provider schema marks app_key (and api_key, bearer_token) as Sensitive: true, which redacts them from CLI output (terraform plan/terraform apply console text) — but Sensitive in a provider schema does not mean encrypted in state. Anyone who can read the raw state file can read those values.
This course doesn’t re-teach Terraform state management — the Terraform (AWS) course already covers remote state backends, state encryption, and team workflows for exactly this problem in depth: see state-management/sensitive-data-and-team-workflows. The Datadog-specific point worth remembering here: a provider "datadog" block with real credentials, applied without a properly access-controlled remote backend, puts your Datadog app key (which, per the RBAC lesson later in this module, can carry the same scoped permissions as a real user) into a file that needs the same protection as any other secret.
Reading AI-generated code
Section titled “Reading AI-generated code”🐛 Spot the bug
# AI comment: "Dashboard widget showing checkout-service CPU"resource "datadog_dashboard" "checkout_overview" {title = "Checkout Service Overview"layout_type = "ordered"
widget { timeseries_definition { request { query { name = "query1" data_source = "metrics" query = "avg:system.cpu.user{service:checkout-service} by {host}" } display_type = "line" } }}}Show the answer
This is syntactically valid HCL — it parses fine — and it looks like a reasonable flattening of the JSON dashboard payload’s queries[] array. It is not valid against the real datadog_dashboard schema. Proven earlier in this lesson: terraform validate against the pinned provider 4.22.0 rejects name, data_source, and query as unsupported arguments directly inside the query block, because query has zero attributes of its own — it only nests further block types, and metric_query (not a flat set of attributes) is where those three fields belong, alongside a sibling formula block that references the query by name. The fix, matching the validated fence earlier in this lesson:
resource "datadog_dashboard" "checkout_overview_fixed" { title = "Checkout Service Overview" layout_type = "ordered"
widget { timeseries_definition { request { query { metric_query { name = "query1" data_source = "metrics" query = "avg:system.cpu.user{service:checkout-service} by {host}" } } formula { formula_expression = "query1" } display_type = "line" } } }}Neither the AI-generated comment nor a quick read of the HCL gives this away — the block nesting looks plausible either way. Only terraform validate against the real, pinned provider schema catches it.
Mental Model
Section titled “Mental Model”The Terraform Datadog provider is a typed front door onto the same REST API the UI itself calls. Every resource "datadog_X" block is Terraform’s own schema description of one API object’s shape — which is exactly why terraform validate can catch a malformed datadog_dashboard widget query without ever contacting Datadog: it’s checking your HCL against the provider’s own compiled-in schema, not against a live API response. The _json-suffixed resources (datadog_dashboard_json, datadog_monitor_json) are the escape hatch from that schema: they hand the provider a raw JSON blob and skip the typed structure entirely, trading structural validation for the ability to paste something the UI already exported. Import (terraform import) is the reverse direction of the same idea — pulling an object that already exists outside Terraform into the provider’s typed view of it, one resource at a time.
Trade-offs
Section titled “Trade-offs”datadog_dashboard/datadog_monitorvs. their_jsoncounterparts — typed HCL blocks give youterraform validate’s structural safety net (proven above: it catches the widget-query bug before anyapply) and cleaner pull-request diffs, at the cost of having to hand-translate every field; the_jsonvariants let you paste a UI export verbatim, at the cost of losing that structural check — a typo inside the JSON string is invisible toterraform validateand only surfaces atapplytime against the real API, or not at all if the field is simply ignored.validate = falseon a monitor query — skipsterraform plan’s real API round-trip to validate the query, useful when a metric genuinely doesn’t exist yet in this environment, at the direct cost of losing the safety net that would otherwise catch a malformed query before it ever reaches production.- Importing vs. recreating —
terraform importpreserves a resource’s history and avoids a delete-then-recreate cycle (and any associated notification/alert gap), but only populates state — the HCL you write still has to be reconciled against whateverterraform planreports as drift afterward, which is manual work proportional to how far the hand-built resource has drifted from a clean baseline. - One log index vs. many, and the order between them — a single broad index is simpler to reason about and order, but a purpose-built index per high-value service (proven above:
checkout-apiwith its own retention and daily limit) needsdatadog_logs_index_orderto be correct, or a broader pre-existing index placed earlier in the list can silently claim logs the narrower index was built to catch.
Common Misconceptions
Section titled “Common Misconceptions”- “The
datadog_dashboardwidget query block takes the same flatname/data_source/queryshape as the JSON API payload.” No — proven above: that flat shape belongs insidedatadog_dashboard_json’sjsonencode(...)body (the real API payload shape), but insidedatadog_dashboard’s typed HCL blocks, those three fields live one level deeper, insidequery.metric_query, alongside a siblingformulablock. - “
DD_SITEcontrols which Datadog site the Terraform provider talks to.” No — proven above by quoting the provider’s own schema documentation directly: the provider’sapi_urlattribute is set viaDD_HOST, notDD_SITE.DD_SITEis real and meaningful for the Agent and for dd-trace/ddtrace tracers (Foundations module), but the Terraform provider does not read it. - “Marking
app_keyasSensitivein the provider schema means it’s encrypted in the state file.” No —Sensitive: trueredacts a value from CLI console output only. The raw value is still written intoterraform.tfstatein plain text by default; protecting that file is a state-backend and access-control problem, covered in the linked Terraform (AWS) course lesson. - “
terraform importwrites the HCL for the resource you’re importing.” No — import only populates Terraform state for a resource block you already wrote (even if empty). You still needterraform planafterward to see, and then reconcile, any difference between your HCL and the real imported object.