---
title: Deployment & Operations
description: Run Meraki Dashboard Exporter in production with containers or Helm, health checks, upgrades, and operational safeguards.
---

# Deployment & Operations

This exporter is distributed as a container image, plus an official Helm chart for Kubernetes. Use the [Getting Started](../getting-started.md) guide for initial setup and the provided [docker-compose.yml](https://github.com/rknightion/meraki-dashboard-exporter/blob/main/docker-compose.yml) as a baseline for production deployments.

## Distribution

For v1, the exporter ships as a **container image + Helm chart only** — there is no PyPI
package, and `pip install meraki-dashboard-exporter` is not a supported install path. If you
need to run it outside a container, clone the repository and run it with `uv` as described in
[Getting Started](../getting-started.md).

## Kubernetes (Helm)

A Helm chart ([`charts/meraki-dashboard-exporter`](https://github.com/rknightion/meraki-dashboard-exporter/tree/main/charts/meraki-dashboard-exporter))
is published to the GHCR OCI registry on every release, alongside the container image:

```bash
helm install meraki-dashboard-exporter \
  oci://ghcr.io/rknightion/charts/meraki-dashboard-exporter \
  --version <exporter-version> \
  --set meraki.apiKey=your_api_key_here
```

Chart versions track exporter release versions (e.g. `0.31.0`); an edge chart tracking `main` is
also published on every push, versioned `0.0.0-main.*`. The chart defaults to a hardened
`securityContext` (non-root, read-only root filesystem) and fails render-time validation unless
exactly one of `meraki.apiKey` / `meraki.existingSecret` is set — prefer `existingSecret` in
production. See [`values.yaml`](https://github.com/rknightion/meraki-dashboard-exporter/blob/main/charts/meraki-dashboard-exporter/values.yaml)
for the full set of configurable settings.

The exporter is a **single-writer singleton** (no leader election), so `replicaCount` must stay `1`
— the chart hard-fails the render otherwise, and its Deployment uses the `Recreate` strategy so a
rollout never briefly runs two pods. Optional resources, all off by default: `ingress.enabled`
(webhook TLS termination, see below), `networkPolicy.enabled` (locks egress to DNS + Meraki API 443
+ the OTLP port), `serviceMonitor.enabled` (Prometheus Operator), and `autoscaling.enabled` (an HPA
that manages the single pod; `maxReplicas` is capped at 1). Read the `resources:` sizing guidance in
`values.yaml` before deploying at scale — the 512Mi default is sized for small orgs only. See the
[Scaling Guide](../scaling-guide.md) for the API-budget sizing formula, per-scale resource tiers, and
shard-by-org / HA recipes.

### Shutdown behaviour and grace period

On `SIGTERM`, the exporter starts an orderly shutdown: in-flight HTTP requests are allowed to
finish and running collector work is given a chance to wind down before the process exits. This
is **best-effort, not guaranteed**, because collector fetches run the synchronous Meraki SDK on a
background thread pool (`asyncio.to_thread`, sized by `executor_workers`) — a thread genuinely
blocked inside an SDK HTTP call cannot be cancelled mid-flight from the asyncio event loop, so
shutdown has to wait for that call to return (either normally or via its own timeout) rather than
being able to kill it instantly.

Two settings bound how long a single blocked fetch can hold things up:

| Setting | Default | Description |
| --- | --- | --- |
| `single_request_timeout` (`MERAKI_EXPORTER_API__TIMEOUT`) | `30s` | Bounds one HTTP request to the Meraki API. |
| `per_fetch_deadline_seconds` | `120s` (`config.apiPerFetchDeadlineSeconds` in Helm) | Bounds how long the exporter awaits a whole logical fetch, including pagination. It cancels the awaiting coroutine, but Python cannot interrupt a synchronous SDK thread that is already in HTTP or pagination work. |

Kubernetes only gives a pod `terminationGracePeriodSeconds` after `SIGTERM` before force-killing it
with `SIGKILL`. If that grace period is shorter than the worst-case blocked fetch, Kubernetes kills
the pod mid-shutdown, which is harmless (the exporter is stateless and safely restartable) but shows
up as noisy `SIGKILL`/`terminated: Error` events instead of a clean exit. The Helm chart's
[`terminationGracePeriodSeconds`](https://github.com/rknightion/meraki-dashboard-exporter/blob/main/charts/meraki-dashboard-exporter/values.yaml)
value defaults to **150s** — comfortably above the `per_fetch_deadline_seconds` default (120s) with
a fixed 30s margin for the deadline to actually fire and the fetch to unwind before Kubernetes gives
up. Set longer fetches with Helm's `config.apiPerFetchDeadlineSeconds`; the chart rejects a
`terminationGracePeriodSeconds` below `per_fetch_deadline_seconds + 30s`, and rejects an `extraEnv`
override of that environment variable so the relationship cannot be bypassed.

That render-time rule prevents a known contradictory configuration; it is not a hard process-exit
guarantee. An SDK pagination call or operating-system DNS lookup already running in a thread cannot
observe coroutine cancellation and may outlive the deadline. During orderly shutdown, DNS, SDK and
registry-serving executors share one **5-second** drain budget. Their blocking joins run on daemon
coordinator threads, so the asyncio event loop and uvicorn shutdown stay responsive. Queued work is
cancelled immediately; the SDK HTTP session closes only after its running workers actually stop. If
the shared budget expires, cleanup remains deferred in the background and the stateless pod relies
on Kubernetes' existing `SIGKILL` grace boundary rather than freezing the event loop indefinitely.

## Endpoints
The exporter exposes endpoints for metrics (`/metrics`), liveness (`/health`),
readiness (`/ready`), an exporter self-health dashboard (`/status`), cardinality
reports (`/cardinality`), and optional client (`/clients`) and webhook
(`POST /api/webhooks/meraki`) features. See the [HTTP Endpoints](../reference/endpoints.md)
reference for the authoritative list and enablement notes.

`/ready` is a startup-only gate: it returns `200` after the initial required collection has
succeeded and remains ready during later upstream failures. This keeps the pod in Service endpoints
so Prometheus can scrape the metrics needed to diagnose the outage. `/health` is the liveness
dead-man check; alert on `time() - meraki_exporter_scheduler_group_success_timestamp_seconds` for
per-endpoint-group freshness and on `meraki_exporter_scheduler_over_budget` for deferred work.

## Webhook receiver (HTTPS / TLS termination)

The exporter can receive Meraki alert webhooks (`config.webhooksEnabled: true`, endpoint
`POST /api/webhooks/meraki`). **Meraki only delivers webhooks over HTTPS** — it rejects plain
`http://` receiver URLs — but the exporter itself serves plain **HTTP**. So you must terminate
TLS in front of it and forward HTTP to the exporter. The receiver URL you configure in the Meraki
Dashboard (Network-wide → Alerts → Webhooks) must therefore be `https://…/api/webhooks/meraki`,
never `http://`. Always set the shared secret (`MERAKI_EXPORTER_WEBHOOKS__SHARED_SECRET`) so payloads are
validated — in the Helm chart, inject it via `extraEnv` (sourced from a Secret) rather than a plain
value.

Pick whichever terminator fits your environment; all three terminate TLS and proxy plain HTTP to
the exporter's service port (`9099` by default).

### Kubernetes Ingress (bundled in the Helm chart)

The chart ships an optional Ingress (`ingress.enabled: true`) that fronts the Service. Terminate
TLS at the ingress (e.g. via cert-manager) and it forwards HTTP to the exporter:

```yaml
# values.yaml
config:
  webhooksEnabled: "true"
extraEnv:
  - name: MERAKI_EXPORTER_WEBHOOKS__SHARED_SECRET
    valueFrom:
      secretKeyRef:
        name: meraki-webhook-secret
        key: secret
ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: meraki-exporter.example.com
      paths:
        - path: /api/webhooks/meraki
          pathType: Prefix
  tls:
    - secretName: meraki-exporter-tls
      hosts:
        - meraki-exporter.example.com
```

Meraki receiver URL: `https://meraki-exporter.example.com/api/webhooks/meraki`.

### nginx (standalone reverse proxy)

```nginx
server {
    listen 443 ssl;
    server_name meraki-exporter.example.com;

    ssl_certificate     /etc/nginx/tls/fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/privkey.pem;

    # Only expose the webhook receiver publicly; scrape /metrics in-cluster/privately.
    location /api/webhooks/meraki {
        proxy_pass http://meraki-dashboard-exporter:9099;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

### Traefik (IngressRoute / dynamic config)

```yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: meraki-exporter-webhook
spec:
  entryPoints:
    - websecure          # the TLS entrypoint
  routes:
    - match: Host(`meraki-exporter.example.com`) && PathPrefix(`/api/webhooks/meraki`)
      kind: Rule
      services:
        - name: meraki-dashboard-exporter
          port: 9099      # plain HTTP to the exporter
  tls:
    certResolver: letsencrypt
```

In every case Meraki talks HTTPS to the terminator, and the terminator talks HTTP to the exporter.

## Monitoring
Prometheus and Grafana integration examples live in the [Integration & Dashboards](../integration-dashboards.md) guide.

## Updating
Pull the latest image and restart the container:
```bash
docker compose pull
docker compose up -d
```

## Troubleshooting
- Check container logs with `docker compose logs meraki_dashboard_exporter`.
- Verify the API key and network connectivity.
- Metrics `meraki_exporter_collector_errors_total` help identify failing collectors.
- Open `/status` for an at-a-glance view of tier health, last collection
  durations, and the effective `NetworkFilter` state (parsed rules plus
  per-org total/resolved network counts, read from the same
  `meraki_network_filter_*` gauges — see [Network Filter](#network-filter)
  below).
- See [Troubleshooting](../troubleshooting.md) for a symptom-driven decision tree
  covering auth errors, empty `/metrics`, readiness gating, per-org backoff,
  rate-limit storms, cardinality shedding, and webhook misconfiguration.

### Alerting on partial collection failures

A collector cycle can **succeed overall** — `/ready` stays `200`, and the coordinator does not
raise — while one or more *sub-phases* inside it fail and are silently tolerated. This is
deliberate resilience (a single broken endpoint, e.g. one MX VPN fetch or one org's config
sub-collection, must never abort the rest of that cycle's collection), but it means the honest
top-level failure signals introduced for [#509](https://github.com/rknightion/meraki-dashboard-exporter/issues/509)
(a raised `_collect_impl()`, `/health` staleness, `meraki_exporter_org_collection_status`) can stay
green for cycles that are nonetheless degraded.

Every one of those tolerated sub-phase failures increments the same counter used for full
collector failures — **`meraki_exporter_collector_errors_total`** (labels: `collector`, `tier`,
`error_type`) — so it is the only signal that surfaces this class of degradation. Alert on its
rate rather than waiting for a full-cycle failure:

The bounded coordinator-category values are `api_rate_limit`, `api_auth_error`,
`api_client_error`, `api_server_error`, `api_not_available`, `connectivity`, `timeout`, `parsing`,
`validation`, and `unknown`; fatal top-level failures use their fixed exception class name (for
example `NothingCollectedError`). `unknown` is reserved for exceptions that genuinely cannot be
classified; alert on it separately because a rise means the classifier needs extending.

```promql
# Any tolerated sub-phase failure in the last 15 minutes, broken out by
# collector/tier/error_type so you can see which sub-system is degraded.
sum by (collector, tier, error_type) (
  rate(meraki_exporter_collector_errors_total[15m])
) > 0
```

```promql
# Sustained partial failure: a collector/tier has logged errors continuously
# for the last hour. Good candidate for a paging alert (vs. the query above,
# which is better suited to a dashboard panel or a low-severity notification).
min_over_time(
  (sum by (collector, tier) (rate(meraki_exporter_collector_errors_total[15m])) > 0)[1h:]
) == 1
```

`NothingCollectedError` now means the collector genuinely obtained no usable result, rather than
one optional endpoint being unavailable. Treat more than five such failures in 15 minutes as a
warning (the supplied `MerakiCollectorErrorsHigh` rule uses this same `> 5` threshold across each
collector); page only when the org status is also `0` or `/health` is stale.

Because this counter increments for tolerated *and* fatal failures alike, corroborate a firing
alert against the [#509](https://github.com/rknightion/meraki-dashboard-exporter/issues/509) health
signals before treating it as critical:

- `up{job="meraki-dashboard-exporter"}` / `/health` - process liveness (unaffected either way).
- `/ready` - startup-only confirmation that the initial required collection completed. It does not
  trip on later failures; use `/health` and the scheduler success timestamps for ongoing health.
- `meraki_exporter_org_collection_status{org_id="..."}` - per-organization gauge, `0` only when
  *every* sub-collection failed for that org this cycle (see `OrganizationCollector`/`OrgHealthTracker`
  in `core/org_health.py`).

If `meraki_exporter_collector_errors_total` is climbing but `/ready` is `200` and
`meraki_exporter_org_collection_status` is `1`, the cycle is completing with **partial** data loss
for the affected sub-phase (e.g. one MR/MS/MX sub-collection, one config sub-collection, or one
org's sensor gateway connections) - worth investigating, but not an outage. If `/health` becomes
stale or the org status gauge drops to `0`, treat it as the higher-severity failure case instead.

## Network Filter
For large organisations, restrict scraping to a subset of networks via the
`MERAKI_EXPORTER_NETWORK_FILTER__*` settings (include/exclude by name glob, ID,
or tag). The filter is inactive by default; if a filter is configured but
resolves to zero networks across all configured orgs at startup, the exporter
exits with an error so typos fail loudly. The refusal requires a successful API response that
proves the filtered result is empty; a transient verification failure is logged and collection
starts normally so the exporter can recover when Meraki does. See `.env.example` and the
[Configuration](../config.md) guide for details.

## Log Aggregation

The exporter outputs structured logs via `structlog`, rendered by
`MERAKI_EXPORTER_LOGGING__LOG_FORMAT` — `json` (the default, suitable for Loki, Datadog, ELK, or
CloudWatch) or opt-in `logfmt`. Both formats
carry the same fields (`level`, `event`, `timestamp`, plus any structured kwargs the log call
attaches); only the wire encoding differs. See [Configuration](../config.md) for the setting.

### Grafana Alloy Configuration

To ship logs to Loki using Grafana Alloy, add this to your Alloy configuration:

```alloy
local.file_match "meraki_exporter" {
  path_targets = [{"__path__" = "/var/log/meraki-exporter/*.log"}]
}

loki.source.file "meraki_exporter" {
  targets    = local.file_match.meraki_exporter.targets
  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://loki:3100/loki/api/v1/push"
  }
}
```

For Docker or Kubernetes deployments, use the container log discovery instead:

```alloy
discovery.docker "meraki" {
  host = "unix:///var/run/docker.sock"
  filter {
    name   = "name"
    values = ["meraki-dashboard-exporter"]
  }
}

loki.source.docker "meraki" {
  host       = "unix:///var/run/docker.sock"
  targets    = discovery.docker.meraki.targets
  forward_to = [loki.write.default.receiver]
}
```

### Example LogQL Queries

These example queries assume the default `MERAKI_EXPORTER_LOGGING__LOG_FORMAT=json`, where the `| json` parser
stage extracts each structured field (e.g. `collector`, `duration`) as a label you can filter or
group on. The line-filter (`|=`/`|~`) portion of every query below also works unchanged against
opt-in `logfmt` output — the raw text still contains the same substrings — but the `| json`
parser stage does not understand logfmt, so swap it for `| logfmt` if you opt in to
`log_format=logfmt`.

**Collector failures:**
```logql
{container="meraki-dashboard-exporter"} |= "Failed to collect" | json
```
With opt-in `log_format=logfmt` instead:
```logql
{container="meraki-dashboard-exporter"} |= "Failed to collect" | logfmt
```

**Rate limit events:**
```logql
{container="meraki-dashboard-exporter"} |~ "rate limit|429" | json
```

**Slow collections (utilization warnings, logged when a collector uses >80% of its cadence):**
```logql
{container="meraki-dashboard-exporter"} |= "Collector utilization high" | json | duration > 60
```

**Error summary by collector:**
```logql
sum by (collector) (count_over_time({container="meraki-dashboard-exporter"} |= "error" | json [1h]))
```

For configuration options see the [Configuration](../config.md) guide. A list of
exported metrics is available in the [Metrics Reference](../metrics/metrics.md).
