Configuration reference
iPrefer a guided form?
The config builder assembles a valid config in your browser, with inline validation and sensible dry-run-safe defaults — nothing you enter leaves the page.
Kapkan is configured by a single YAML file passed with -config. The repository ships two
starting points: configs/dev.yaml for local development (test prefixes,
loopback bind, dry_run: true) and engine/deploy/config.example.yaml for
production, which carries authoritative inline comments and defaults. Copy the production
example to /etc/kapkan/config.yaml and adapt it to your network.
iNew to BGP / RTBH / FlowSpec?
This page assumes you know what these terms mean. If you don't, read How it works first, or keep the glossary open in another tab — every acronym below is defined there. In short: BGP (Border Gateway Protocol) is how routers exchange routes, and Kapkan acts as a BGP speaker that tells your routers which routes to install in order to mitigate an attack.
The file is reloadable at runtime. Send SIGHUP (systemctl reload kapkan) or
POST /api/v1/config/reload and Kapkan re-reads the file and applies the new settings
without dropping flow ingestion. Almost everything reloads live, including dry_run,
thresholds, networks, hostgroups, baselines, notification settings and
sampling.default_rate. Seven things are fixed at startup — a reload that changes any of them is
rejected (not silently ignored), so you must restart the daemon to change them:
- the
listenaddresses; - BGP identity (
bgp.local_asn,bgp.router_id); api.listen;- the
samplessettings; storagesettings;geoipsettings;- the data-plane attachment and map sizing (
dataplane.enabled,interfaces,xdp_mode,pin_path,on_exitand everything underlimits) — none of these can change under a loaded XDP program. The static policy in the same block —allowlist,static_rules,ratelimit_profiles— reloads live.
!Dry-run defaults to on
dry_run defaults to true, and an absent dry_run key is treated as true. Keep it that
way until you have validated detection against live telemetry. See Going live.
Validating the configuration
Before you reload or go live, check a file with the engine's own validator:
kapkan -check-config /etc/kapkan/config.yaml
It runs the exact same parse and validation the daemon does — including the cross-field rules a
form cannot express (no overlapping networks, hostgroup prefixes contained by a protected
network, monotonic escalation ladders) — then prints the resolved
configuration (mode, protected networks, and the effective mitigation per
hostgroup) and exits 0. On any problem it prints the exact error and exits
1, so it drops cleanly into CI or a pre-deploy check.
kapkan -dump-schema prints the configuration's JSON schema to stdout; the
config builder consumes that schema to validate fields in your browser as you type.
Both flags, and every other one the binary accepts, are listed in the CLI reference.
Secrets and environment variables
Secrets are never written in the config file. Each one names an environment variable, and Kapkan reads the value from the process environment at load time:
notify.telegram.token_env— the Telegram bot token.notify.email.username_env/notify.email.password_env— SMTP credentials.storage.clickhouse.username_env/password_env— ClickHouse credentials.api.token_env(or eachapi.tokens[].token_env) — the REST API / dashboard bearer token(s).
With the systemd unit, these live in an EnvironmentFile such as /etc/kapkan/kapkan.env
with 0600 permissions:
KAPKAN_TG_TOKEN=123456:abc...
KAPKAN_API_TOKEN=a-long-random-string
Top-level keys
Every top-level key, its meaning, and its default. For the larger subsystems, a one-line summary points to the dedicated page.
iMinimum config to run
Most keys below are optional. To start the daemon at all you only need: at least one listen
address, sampling.default_rate (>= 1), at least one protected prefix in networks, and
thresholds.pps / .mbps / .flows_per_sec (all > 0). The default api.listen is
127.0.0.1:8080, so you can leave it out. To actually announce blackholes (i.e. once you turn
off dry_run) you also need a bgp block. Everything else — carpet, baseline,
flowspec.source_anchored, flow_sources, geoip, escalation, dataplane — is an optional
refinement.
| Key | Meaning |
|---|---|
dry_run | When true (the default, including when absent), blackholes are logged and tracked but never announced. |
listen.sflow / listen.netflow | UDP listen addresses. NetFlow v5/v9 and IPFIX share the netflow socket. At least one listener is required. See How it works. |
sampling.default_rate | Sampling rate used when an exporter does not report its own. Must be >= 1. |
sampling.boundary | Optional per-exporter interface-boundary counting that deduplicates a flow seen at multiple sampling vantage points (redundant exporters, ingress+egress sampling, transit links). Each entry gives an exporter's external_ifindexes (and optional egress_sampling); a flow is then counted only when it crosses an external interface. Exporters without an entry count every sample. See Sampling correction below. |
sampling.boundary_debug | When true, exports kapkan_engine_boundary_debug_bytes_total (bytes per exporter and interface) to help identify external interfaces. Not cardinality-bounded — enable briefly, then disable. |
flow_sources | Optional allowlist of trusted exporter source addresses. Telemetry arrives over unauthenticated UDP, so when set only listed sources are labeled by exporter on metrics (everything else is bucketed under other), bounding metric label cardinality. Empty applies an automatic cap instead. Does not affect detection. |
networks | Protected prefixes (IP ranges in CIDR form, e.g. 203.0.113.0/24). Detection applies only to destinations inside these, and they must not overlap. |
protected_whitelist | Addresses that are never banned, regardless of traffic. See Safety model. |
thresholds.pps / .mbps / .flows_per_sec | Per-destination thresholds after sampling correction. All must be > 0. See Detection. |
thresholds.tcp_pps / udp_pps / icmp_pps / tcp_syn_pps / frag_pps (and each _mbps variant) | Optional per-protocol limits. A value of 0 or an absent key disables that limit. Any crossed threshold triggers (OR semantics). |
thresholds_outgoing | Optional. Detects attacks originated by protected hosts (compromised machines). Same keys as thresholds; at least one must be set. Absent, outgoing traffic is not counted. See Detection. |
hostgroups[] | Optional named prefix groups with their own thresholds and mitigation policy, including per_host or total calculation. Each may carry a tenant label. See Hostgroups. |
tenant | Optional tenant label for the implicit global/fallback group. See Multi-tenancy. |
samples.enabled / buffer_flows / flows_per_attack | Continuous traffic buffer for attack samples (defaults: true / 65536 / 20; buffer_flows max 1048576). Sizing changes require a restart. |
baseline | Optional continuous learned per-host thresholds (EWMA — exponentially weighted moving average), per-hostgroup overridable. See Baselines. |
carpet | Optional carpet-bombing (subnet-spread: volume spread thinly across many addresses in a prefix) detection: raises a prefix-scoped attack when traffic aggregated over a /24 (aggregation_prefix_v4/v6) crosses carpet.thresholds across at least min_hosts distinct hosts — catching floods that stay under every per-host threshold. Alert-only unless carpet.mitigation (flowspec, dataplane or blackhole) is set; dataplane drops the vector in this box's kernel and requires a dataplane block with enabled: true. Every method refuses a prefix containing a whitelisted address, counts the prefix's full address span toward ban.max_banned_fraction, and is capped by carpet.max_active_prefix_bans. |
ban.ttl_seconds | How long a ban lasts without renewed detection — a sustained attack keeps refreshing it, so the route auto-withdraws this long after detection stops. No permanent bans. |
ban.unban_hysteresis_seconds | Traffic must stay below threshold this long before a ban is withdrawn, preventing flapping. |
ban.max_active_bans | Hard cap on simultaneous bans. New bans past the cap are refused. |
ban.fallback | What to do when a flowspec/divert announce is rejected by the peer: blackhole (default) degrades to an RTBH route (Remotely-Triggered Blackhole — a route that null-routes all traffic to the victim IP) so the victim is still mitigated; none rejects the ban. Blackhole is terminal. |
ban.max_banned_fraction | Refuse new bans once more than this share (0–1, per address family) of the protected space is blackholed — bounds the blast radius of a poisoned baseline or spoofed-source storm. 0 (default) disables it. |
ban.max_bans_per_window / ban_window_seconds | Storm guard: at most this many new bans per window (seconds). max_bans_per_window: 0 (default) disables it; the window is required when the rate is set. |
ban.state_file | Optional writable path (e.g. /var/lib/kapkan/bans.json) where active bans are persisted so a restart rehydrates and re-announces them instead of dropping mitigation until re-detection. Pairs with bgp.graceful_restart. Empty (default) disables persistence. See Mitigation. |
mitigation | Mitigation method: blackhole (default), flowspec (FlowSpec — a BGP extension that filters on more than the destination IP, e.g. port/protocol, so you can drop just the attack traffic), divert, or dataplane (drop in this host's own kernel, announcing nothing — requires a dataplane block with enabled: true, or the config is rejected at startup). Per-hostgroup overridable. See FlowSpec, Traffic diversion and In-kernel data plane. |
flowspec.action / rate_mbps | FlowSpec rule action: discard or rate_limit (with rate_mbps). See FlowSpec. |
flowspec.source_anchored / min_source_concentration | When on, a flowspec rule pins both the victim (destination) and a dominant attacker source from the attack sample — dropping only the attackers, sparing legitimate clients — but only when the sample is concentrated enough (default 0.8); otherwise it falls back to victim-anchored. Default off. |
escalation[] | Optional ladder of { after_seconds, action } rungs (none / dataplane / flowspec / divert / blackhole, in that order of severity) that supersedes mitigation. The ladder must be non-decreasing: it may hold or strengthen, never soften. A dataplane rung requires a dataplane block with enabled: true. See Escalation ladders. |
dataplane | Optional in-kernel filter: Kapkan attaches an XDP program to this host's own NICs and drops attack traffic as the driver receives it, instead of announcing a route and asking a router to. Absent (the default) it is off entirely, and no other block references it. It needs Linux 5.15 or newer plus a systemd drop-in granting CAP_BPF, CAP_NET_ADMIN and CAP_PERFMON — read In-kernel data plane before you enable it. |
dataplane.enabled | Defaults to true when the block is present, so this key exists to switch a configured block off without deleting it. false resolves the block away entirely: a dataplane rung, mitigation: dataplane or carpet.mitigation: dataplane is then rejected at startup exactly as if the block were absent. |
dataplane.interfaces / xdp_mode | The NICs to attach to (required, at least one; names must be unique and valid — up to 15 characters), and how: auto (default) tries native driver-level XDP and falls back to the generic path, native fails outright when the driver has no native XDP, generic forces the slower skb path (virtio, testing). Whether a named NIC actually exists is answered at attach time, by a startup error. |
dataplane.pin_path / on_exit | The bpffs directory holding the pinned program and maps, so policy survives a restart of this process (default /sys/fs/bpf/kapkan; must be absolute, and Kapkan refuses to start unless it is mode 0700 and owned by the uid the daemon runs as). on_exit is what a clean shutdown does: keep (default) leaves the program attached and still enforcing static policy, detach removes it entirely. |
dataplane.drop_malformed | Drop frames that cannot be parsed instead of passing and counting them. Default false: this is a mitigation executor, not a firewall, so its default verdict is pass and unrecognised is not treated as hostile. |
dataplane.allowlist | Source prefixes — or bare addresses, taken as host routes — that always pass, checked before every other rule. Note the axis: this is who traffic comes from, whereas protected_whitelist names destinations that are never banned. Both are enforced in the kernel, and neither substitutes for the other. Empty by default; hot-reloads. |
dataplane.ratelimit_profiles[] | Named ceilings referenced by a static rule's profile: each entry has a unique name plus pps and/or mbps (at least one must be set; 0 leaves that dimension unlimited). A profile nothing references is dropped on reload. Empty by default; hot-reloads. |
dataplane.static_rules[] | Always-on operator rules, evaluated after the allowlist and before anything the detector installs. Each has a unique name, an action (pass / drop / ratelimit, the last requiring profile) and a match of src, proto (tcp / udp / icmp / icmp6), src_port, dst_port and payload. Match fields are ANDed and an unset field matches anything — so an empty match matches every packet, which only makes sense with ratelimit. Ports are rejected for icmp/icmp6. payload narrows on what the payload begins with; its only value is tls_client_hello (a TCP segment opening a TLS ClientHello — the shape of a handshake flood), it requires proto: tcp, and it never reassembles a stream, so a split ClientHello does not match and is forwarded. Rules are first match wins, so a rule whose match an earlier rule already covers can never fire; Kapkan reports such rules — and any the allowlist covers — on every apply rather than rejecting the config, see Rules that can never fire. Empty by default; hot-reloads. |
dataplane.limits.max_dynamic_rules / max_static_rules / max_ratelimit_sources | BPF map sizes, allocated in full when the program attaches (defaults 4096 / 256 / 1048576; each must be > 0). max_dynamic_rules bounds the rules the mitigator may install and must be at least ban.max_active_bans * 8, since one ban may install up to 8 match rules — Kapkan rejects a smaller value rather than letting installs fail mid-attack, and at the default max_dynamic_rules of 4096, 512 active bans is exactly the ceiling. max_static_rules must also cover the static_rules you wrote. max_ratelimit_sources sizes the per-source token-bucket table and is the only one of the three that costs real memory. See In-kernel data plane. |
bgp.local_asn / router_id / next_hop / next_hop6 / community | BGP identity, IPv4/IPv6 blackhole next-hops, and the RTBH community — a route tag in ASN:value form (e.g. 65000:666) that your upstream matches to trigger the blackhole. router_id must be an IPv4 dotted-quad. next_hop6 is optional even when an IPv6 prefix is in networks; left unset, IPv6 blackholes use the RFC 6666 discard next-hop 100::1. See Mitigation. |
bgp.communities / local_pref | Optional community list (overrides community) and a LOCAL_PREF (a BGP route-preference attribute) for iBGP (internal BGP) peers — LOCAL_PREF only travels within your own network, not to external (eBGP) peers, so it is meaningful only to your internal neighbors. All bgp attributes are per-hostgroup overridable. |
bgp.neighbors[] | eBGP (external BGP) peers: address, remote_asn, and an optional port for testing. |
bgp.graceful_restart | BGP Graceful Restart (RFC 4724), on by default. A peer that supports it retains Kapkan's mitigation routes as stale while the session is down (e.g. an upgrade) instead of flushing them. Keys: enabled (default true), restart_seconds (default 120, range 0–4095, where 0 means "use the default 120"), long_lived (LLGR — Long-Lived Graceful Restart, default false) and long_lived_stale_seconds (default 3600, ≤ 86400). See Mitigation. |
scrubbing.next_hop / next_hop6 / community / communities / local_pref | The scrubbing center the divert action steers traffic to. next_hop is required to divert (next_hop6 too when IPv6 is protected); the community is optional. Per-hostgroup overridable. See Traffic diversion. |
scrubbing.nodes[] / node_selection / on_all_nodes_lost / stale_after_seconds | Managed scrubbing nodes — boxes running kapkan scrub that Kapkan diverts to and tells what to drop. Each node has a name, next_hop/next_hop6, optional capacity_mbps and hostgroups. A group whose ladder diverts needs a node that serves it or the scalar next_hop. See Traffic diversion. |
notify.* | Telegram, Slack, email, webhook and exec-hook channels. See Notifications. |
storage.clickhouse.* | Optional ClickHouse persistence for attack and traffic history (url, database, username_env, password_env, ttl_days, …). See Storage. |
geoip | Optional MaxMind GeoLite2/GeoIP2 ASN (and country) attribution of attack-sample sources (enabled, asn_database, country_database). Off by default; fixed at startup. |
api.listen | REST API and metrics listen address. Default 127.0.0.1:8080. |
api.dashboard | Serve the embedded web UI at / on the API listener. Default true. See Dashboard. |
api.token_env | Names the env var holding a single operator bearer token. Required before exposing the listener beyond localhost. See Authentication. |
api.tokens[] | Role-based tokens: each entry has a name, a token_env, a role (viewer or operator), and an optional tenant scope. Supersedes token_env (mutually exclusive). See Authentication and Multi-tenancy. |
update_check | Optional, opt-in check for a newer Kapkan release — off by default, so Kapkan never phones home unless you enable it. Keys: enabled (default false), interval_seconds (default 21600 = 6h; must be >= 3600 / 1h or the config is rejected at load), channel (stable or prerelease), notify (also push "update available" to your notification channels the first time a new version is seen; default false) and an optional url mirror override. When on, it polls the GitHub Releases API and surfaces a result on /api/v1/status and the kapkan_update_available metric. The running version is always exposed locally regardless. You can also run kapkan -check-update to perform the check once on demand without enabling the poller (exit 10 = update available, 0 = up to date, 1 = error). |
Thresholds detail
The three base thresholds — pps, mbps, flows_per_sec — are mandatory and must all be
> 0. The per-protocol limits are optional refinements: tcp_syn_pps counts pure SYNs (SYN
set, ACK clear) and frag_pps counts non-first IP fragments. Detection uses OR semantics —
any single crossed threshold trips the attack. All values are expressed in real, unsampled
units (see Sampling correction).
flows_per_sec is meaningful only for NetFlow/IPFIX exporters. sFlow exports one sample per
packet (no flow aggregation), so sFlow-sourced hosts always report flows_per_sec = 0 and that
threshold never trips for them — use pps/tcp_syn_pps/udp_pps instead. See Detection.
A complete example
A realistic production config, adapted from engine/deploy/config.example.yaml:
dry_run: true # keep until detection is validated
listen:
sflow: ":6343"
netflow: ":2055" # NetFlow v5/v9 + IPFIX share this socket
sampling:
default_rate: 1000 # used only when an exporter omits its own rate
# boundary: optional interface-boundary counting (deduplicates a flow seen at
# several vantage points). List each exporter's external/uplink interfaces;
# set egress_sampling only if calibration shows that ifIndex double-counts (see
# Sampling correction) — not just because the device samples on egress.
# boundary_debug: false
# boundary:
# - exporter: "10.1.32.2"
# external_ifindexes: [1000025]
# egress_sampling: false # true only if boundary_debug shows ~2x
networks: # detection applies ONLY inside these prefixes
- "203.0.113.0/24"
- "2001:db8::/32"
protected_whitelist: # never banned, regardless of traffic
- "203.0.113.1" # gateway / router
- "203.0.113.2" # authoritative nameserver
thresholds: # per destination host, after sampling correction
pps: 80000
mbps: 1000
flows_per_sec: 35000
tcp_syn_pps: 5000 # optional per-protocol; 0/absent disables
udp_pps: 60000
thresholds_outgoing: # detect compromised hosts attacking outward
pps: 50000
udp_pps: 20000
dataplane: # optional; needs Linux 5.15+ and the systemd drop-in
interfaces: ["eth0"] # required; at least one NIC (restart to change)
# xdp_mode: auto # auto (default) | native | generic
# pin_path: /sys/fs/bpf/kapkan # bpffs dir; mode 0700, owned by the daemon's uid
# on_exit: keep # keep (default) stays attached and enforcing on shutdown
# drop_malformed: false # default: unparseable frames pass and are counted
allowlist: # SOURCE prefixes that always pass, checked first
- "198.51.100.7" # monitoring (a bare address is a host route)
- "192.0.2.0/24" # management network
ratelimit_profiles: # named ceilings; set pps, mbps or both
- {name: icmp_cap, mbps: 10}
- {name: handshake_cap, pps: 20}
static_rules: # always-on, before any rule the detector installs
- name: drop_chargen
match: {proto: udp, src_port: 19}
action: drop
- name: cap_icmp
match: {proto: icmp}
action: ratelimit
profile: icmp_cap # must name a declared profile
- name: cap_tls_handshakes # per-source ceiling on new TLS handshakes
match: {proto: tcp, dst_port: 443, payload: tls_client_hello}
action: ratelimit
profile: handshake_cap
# limits:
# max_dynamic_rules: 4096 # must be >= ban.max_active_bans * 8
# max_static_rules: 256
# max_ratelimit_sources: 1048576 # per-source token buckets (kernel memory)
ban:
ttl_seconds: 600 # every ban auto-withdraws after this
unban_hysteresis_seconds: 120 # stay below threshold this long before unban
max_active_bans: 50 # refuse new bans past this cap
# fallback: blackhole # degrade a rejected flowspec/divert announce to RTBH (default)
# max_banned_fraction: 0.05 # never blackhole more than 5% of protected space (per family)
# max_bans_per_window: 100 # storm guard: at most 100 new bans per window
# ban_window_seconds: 60 # the window for max_bans_per_window
bgp:
local_asn: 65001
router_id: "10.0.0.1" # must be an IPv4 dotted-quad
next_hop: "192.0.2.1" # IPv4 blackhole (discard) next-hop
next_hop6: "100::1" # IPv6 blackhole next-hop
community: "65000:666" # RTBH community your upstream honors
neighbors:
- address: "10.0.0.254"
remote_asn: 65000
notify:
telegram:
token_env: "KAPKAN_TG_TOKEN" # token read from this env var, never the file
chat_id: "-1001234567890"
slack:
webhook_url: "" # optional Slack incoming webhook
email:
smtp_host: "" # "mail.example.com:587"; empty disables
from: ""
to: []
username_env: "KAPKAN_SMTP_USER"
password_env: "KAPKAN_SMTP_PASS"
require_tls: false # STARTTLS auto-required when credentials are set
api:
listen: "127.0.0.1:8080" # default localhost bind needs no auth
dashboard: true # embedded web UI at /; false = JSON API only
# token_env: "KAPKAN_API_TOKEN" # REQUIRED before exposing beyond localhost
The hostgroups, baseline and samples blocks are omitted here for brevity. See the
dedicated pages for their full schemas:
- Hostgroups — per-prefix thresholds,
ban: false, andcalculation: total. - Baselines —
factor,half_life_seconds,warmup_seconds,floor. - The
samplesblock:enabled(defaulttrue),buffer_flows(default65536, max1048576) andflows_per_attack(default20). These control the continuous flow ring used to attach dominant sources, ports and protocols to each attack the moment it trips. Changing the sizing requires a restart.
The dataplane block above is shown for shape. It is not self-sufficient: a host with
dataplane.enabled: true fails at startup unless the kernel is 5.15 or newer and the shipped
kapkan-dataplane.conf systemd drop-in is installed, because the hardened unit grants none of the
three capabilities the XDP program needs. Install the drop-in with the config change, not after the
first failed start — In-kernel data plane covers the host preparation, the map
memory to budget for, and what each startup error means.
Sampling correction
Flow telemetry is sampled — a router exports one record per N packets. Kapkan multiplies
every observed rate by the exporter's sampling rate so thresholds are expressed in real,
unsampled traffic units. The rate comes from the flow packet when the exporter reports it,
otherwise from sampling.default_rate.
This means a threshold like pps: 80000 is the real attack rate you want to act on, not a
sampled count. If your exporter samples at 1:1000 and does not advertise the rate, set
sampling.default_rate: 1000 so a single sampled packet-per-second is correctly counted as
1000 real packets-per-second.
!Get sampling right before going live
An incorrect sampling.default_rate scales every threshold by the same factor. Set it to
match what your routers actually export, then confirm detection fires at the rates you expect
in dry-run.
Counting each packet once
Correction assumes each packet is observed once. When the same packet is sampled at more than one
vantage point — redundant exporters (e.g. an MLAG pair), ingress and egress sampling on one
device (Arista sflow sample output), or transit/peer links — Kapkan would otherwise sum every
copy and over-count pps, mbps and flows_per_sec by a constant factor.
Interface-boundary counting fixes this without changing the exporter. Classify each exporter's
external (uplink/border) interfaces; a flow is then counted toward a protected host only when it
crosses the boundary — inbound on an external input interface, outbound on an external output one.
Exporters without a boundary entry keep counting every sample, so you can enable this one exporter
at a time.
sampling:
default_rate: 2500
boundary:
- exporter: "10.1.32.2" # one entry per flow exporter (sampler/agent IP)
external_ifindexes: [1000025] # the uplink/border interface ifIndex values
egress_sampling: false # only if calibration shows this ifIndex double-counts (see below)
To discover which interfaces are external, set sampling.boundary_debug: true briefly and read
kapkan_engine_boundary_debug_bytes_total broken down by exporter and interface.
!Calibrate egress_sampling — do not assume it
egress_sampling: true halves the boundary-counted rate to undo an in+out double-count, but
sflow sample output does not automatically mean you need it. Whether an egress copy actually
inflates the boundary depends on topology: on an Arista MLAG the egress copy of an inbound packet is
sampled on the access port with a different input interface, so it never enters the boundary's
input-interface gate — the packet is already counted once, and egress_sampling: true would then
under-count it by half. Calibrate: compare the external interface's boundary_debug rate to that
interface's real counter (e.g. show interfaces). Set egress_sampling: true only if the boundary
interface shows ~2x the real rate; otherwise leave it false (the default).
Related
- Detection & thresholds — what counts as an attack, per-protocol limits and outgoing detection.
- Hostgroups — per-prefix policies and total-traffic groups.
- Baselines — continuous learned thresholds.
- Mitigation — BGP, RTBH, TTLs and ban caps.
- In-kernel data plane — the
dataplaneblock in full: host preparation, evaluation order and map memory. - Authentication — securing the API and dashboard.
- Going live — validate, then turn off dry-run.