Spitfire
InstallDocker · Kubernetes · Windows · runner

Installation guide

Installing is one command. It downloads the release's installer bundle from spitfire.tr, checks its SHA-256 and unpacks it into ~/spitfire; the image comes from Docker Hub. The same command also updates, keeping your settings.

Requirements

  • Linux or macOS with Docker and Docker Compose v2.
  • Windows 10/11 or Windows Server: Docker Desktop (Linux containers) and PowerShell. On a Windows server without Docker a runner installs as a Windows service.
  • For Kubernetes also kubectl and access to a cluster.
  • Access to Docker Hub and spitfire.tr. No Go, Node or source code needed.

Single machine (Docker)

Starts Postgres, the controller, the web UI and 2 runners.

curl -fsSL https://spitfire.tr/install.sh | bash -s -- docker

Options: --port 8470, --runners 4, --location istanbul; see ~/spitfire/install.sh --help.

Kubernetes

Installs into the current kubectl context, namespace spitfire; the nodes pull the image from Docker Hub.

curl -fsSL https://spitfire.tr/install.sh | bash -s -- kubernetes
curl -fsSL https://spitfire.tr/install.sh | bash -s -- kubernetes --context prod --namespace loadtest --runners 3

Pods run as non-root with a read-only root filesystem, no capabilities and the RuntimeDefault seccomp profile. NetworkPolicies let only the controller reach Postgres and only this installation's runners reach the plaintext runner port (8475), where the CNI enforces them 0.5.5+.

Windows (Docker Desktop)

One PowerShell command: it verifies the installer bundle, unpacks it into %USERPROFILE%\spitfire and starts Postgres, the controller and 2 runners on Docker Desktop. No administrator rights needed; the script is allowed to run for that process only.

irm https://spitfire.tr/install.ps1 | iex

With options (port, number of runners, location):

& ([scriptblock]::Create((irm https://spitfire.tr/install.ps1))) docker -Port 8470 -Runners 4 -Location istanbul

A runner on a Windows server without Docker: run the command from Runners → Registration key → Windows in an Administrator PowerShell. The runner becomes the service spitfire-runner-1, started with Windows and restarted if it crashes; its settings live under %ProgramData%\Spitfire, readable only by SYSTEM and Administrators.

Setup code: Select-String SPITFIRE_SETUP_CODE $env:USERPROFILE\spitfire\deploy\docker\.env. Removal: powershell -ExecutionPolicy Bypass -File $env:USERPROFILE\spitfire\uninstall.ps1 docker. Windows install ships with release 0.5.3.

First sign-in and the setup code

The installer prints the address and a one-time setup code. Open the address and create the first admin with the code.

Lost the code? No new one is needed; it is kept until the first admin exists:

grep SPITFIRE_SETUP_CODE ~/spitfire/deploy/docker/.env
kubectl -n spitfire get secret spitfire-secrets -o jsonpath='{.data.SPITFIRE_SETUP_CODE}' | base64 -d

The session key is kept in a cookie scripts cannot read (HttpOnly); the session survives a reload, and when it ends the UI returns to the sign-in screen 0.5.6+.

Sign-in attempt limits 0.5.4+

Against password guessing, sign-in, the setup code and the SSO return are limited: one client address may try 30 times in a row, then once every 2 s; an account accepts 10 wrong passwords, then one a minute (from any address; a correct password does not count). Past a limit the screen says how long to wait, and the API answers 429 with Retry-After.

If many users come through one proxy or NAT, raise the address limit: SPITFIRE_SIGNIN_BURST=200 in deploy/docker/.env (or spitfire-secrets). The client address comes from X-Forwarded-For only when the request arrives from loopback or a private network (an ingress or reverse proxy in front); a proxy on a public address (a cloud load balancer, Cloudflare) goes in SPITFIRE_TRUSTED_PROXIES 0.5.5+.

For an API that wants a client certificate (mTLS) or uses a private CA, add Connections → Client certificate (mTLS); HTTP, WebSocket and SSE steps pick it under Client certificate. For a ready test file, Tests → Open JSON (examples: spitfire.tr/en/examples) 0.5.13+.

Runners in other locations

Remote runners dial out to the controller over TLS with key pinning; no inbound port on the load server. In the UI, Runners → Registration key prepares the command with the token and pin:

curl -fsSL https://spitfire.tr/install.sh | bash -s -- runner --controller spitfire.example.com:8471 --token sfrun_… --ca-pin sha256:… --runners 2

For servers without Docker the same dialog also has a single-binary install with systemd.

Runners installed without Docker (systemd or the Windows service) update themselves from then on: press Update next to an older runner on the Runners page. The runner downloads the new release from the controller over the gRPC port, checks Spitfire's signature itself (it accepts nothing unsigned or older) and restarts; a runner in a run is not updated. Runners in Docker and Kubernetes update with their image, through the install command. Runners installed earlier get this after running their install command once more 0.5.9+. With Update runners automatically on (Runners page) the controller does it itself: idle runners, one at a time per location 0.5.11+.

CI/CD 0.5.3+

A saved test runs from the pipeline on the controller, on your runners; the step waits and passes or fails on the thresholds. Credential: a personal token from profile menu → API tokens, stored as the pipeline secret SPITFIRE_TOKEN. The token can only read, start and stop runs. The CI button on a test's page prepares the steps below with that test's name.

GitHub Actions

# .github/workflows/load-test.yml içinde bir adım / a step
- name: Load test
  env:
    SPITFIRE_URL: https://spitfire.example.com
    SPITFIRE_TOKEN: ${{ secrets.SPITFIRE_TOKEN }}
  run: |
    curl -fsSL "$SPITFIRE_URL/api/v1/runner-dist/spitfire-cli-linux-amd64.gz" | gunzip > spitfire && chmod +x spitfire
    ./spitfire cloud run "Checkout" --report report.pdf

GitLab CI

load-test:
  image: basistekbt/spitfire:latest
  variables:
    SPITFIRE_URL: https://spitfire.example.com   # SPITFIRE_TOKEN: CI/CD → Variables (masked)
  script:
    - spitfire cloud run "Checkout" -l istanbul=60 -l frankfurt=40 -o summary.json

Windows (PowerShell)

$env:SPITFIRE_URL = 'https://spitfire.example.com'   # $env:SPITFIRE_TOKEN: pipeline secret
Invoke-WebRequest "$env:SPITFIRE_URL/api/v1/runner-dist/spitfire-cli-windows-amd64.exe" -OutFile spitfire.exe -UseBasicParsing
.\spitfire.exe cloud run 'Checkout'
exit $LASTEXITCODE

Exit codes: 0 passed, 99 thresholds failed (a threshold that measured nothing counts as failed), 97 a threshold aborted the run, 2 the test changes data and --confirm-writes was not given, 1 other errors. Options: --runners N, -l istanbul=60:2 (location share), --label zone=a, --timeout 20m, -o summary.json, --report report.pdf, --report-lang en.

Integrations 0.5.3+

In the UI, Integrations (admin): announce run start and end with a webhook, and hand live metrics to your monitoring over OpenTelemetry or Prometheus. Set Spitfire's public address on the same page first; run links in the notifications use it.

Webhook

On the selected events (run.started, run.finished, schedule.failed) JSON is POSTed to the URL. Headers: X-Spitfire-Event, X-Spitfire-Delivery, X-Spitfire-Schema; with a secret, X-Spitfire-Signature: sha256=<hex> (the HMAC-SHA256 of the body). Extra headers (e.g. the receiver's token) can be added. 2xx is success; network errors, 408, 429 and 5xx are retried after 2 s, 15 s and 1 min, other 4xx are not. Every attempt shows under Deliveries. targets are the hosts the test loads; summary comes with run.finished only. Within a schema version fields are only ever added.

{
  "schema": "spitfire.run-event/v1",
  "event": "run.finished",
  "deliveryId": "6f1c…",
  "sentAt": "2026-09-29T12:00:58Z",
  "controller": { "url": "https://spitfire.example.com", "version": "0.5.13" },
  "run": {
    "id": "3b9e…", "url": "https://spitfire.example.com/runs/3b9e…",
    "testId": "a1f0…", "testName": "Checkout", "testVersion": 7,
    "status": "finished", "result": "thresholds_failed",
    "startedAt": "2026-09-29T11:55:00Z", "endedAt": "2026-09-29T12:00:57Z",
    "plannedSeconds": 360, "plannedEndAt": "2026-09-29T12:01:00Z",
    "maxVUs": 200, "runners": 2, "locations": ["istanbul=60%", "frankfurt=40%"],
    "requestedBy": "Ayşe", "note": "GitHub Actions · shop@1a2b3c4d", "tags": ["ci"],
    "modifiesData": false
  },
  "targets": [
    { "protocol": "http", "host": "shop.example.com", "url": "https://shop.example.com", "steps": 3 },
    { "protocol": "postgres", "host": "db.internal", "port": "5432", "connection": "orders", "steps": 1 }
  ],
  "summary": {
    "requests": 182340, "failed": 12, "rps": 506.5, "errorRate": 0.00007,
    "p50Ms": 38, "p95Ms": 612, "p99Ms": 980, "peakVUs": 200, "checksPassRate": 0.999,
    "thresholds": [{ "metric": "req_duration", "expr": "p(95)<500", "observed": 612, "passed": false }]
  }
}

If the controller restarts while a run is live, the run closes as interrupted and its run.finished is sent at startup (result: error, abortReason: controller restarted), so the receiver does not keep it open (0.5.4+).

Verifying the signature

import hashlib, hmac

def verified(body: bytes, header: str, secret: str) -> bool:
    # header: the X-Spitfire-Signature value, "sha256=<hex>"
    want = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, header or "")
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verified(body, header, secret) {   // body: the raw request bytes
  const want = Buffer.from('sha256=' + createHmac('sha256', secret).update(body).digest('hex'))
  const got = Buffer.from(header ?? '')
  return got.length === want.length && timingSafeEqual(got, want)
}

Prometheus

/metrics: runners (spitfire_runners), active runs and each live run's spitfire_run_vus, spitfire_run_requests_per_second, spitfire_run_error_ratio, spitfire_run_latency_ms{quantile}, spitfire_run_requests_total, spitfire_step_requests_total. Credential: a personal API token.

The controller's own health is on the same endpoint 0.5.5+: spitfire_internal_runner_messages_dropped_total, spitfire_internal_db_writes_dropped_total / _failed_total (above zero, the results have gaps), spitfire_internal_deliveries_total{result}, spitfire_internal_deliveries_pending (webhooks waiting), spitfire_internal_http_responses_total{code}, plus Go runtime values. For log collectors: SPITFIRE_LOG_FORMAT=json (runners: SPITFIRE_RUNNER_LOG_FORMAT).

scrape_configs:
  - job_name: spitfire
    scheme: https
    metrics_path: /metrics
    authorization:
      credentials: sfpat_…        # a personal API token
    static_configs:
      - targets: ['spitfire.example.com']

OpenTelemetry (OTLP)

While a run is live its metrics are pushed over OTLP/HTTP (JSON) to a collector: the endpoint is the base URL, /v1/metrics is appended (e.g. https://otel-collector:4318); credential headers (Authorization, Api-Token…) are stored encrypted. Metrics: spitfire.vus, spitfire.request.rate, spitfire.request.error_rate, spitfire.request.duration.p50/p95/p99, spitfire.requests, spitfire.step.requests; attributes spitfire.run.id, spitfire.test.name and server.address for the target host.

Reports 0.5.3+

On a run's page, Report: HTML (new tab), PDF, CSV (step and location summary, or per second). A share link shows the report to someone without an account for 1–90 days; it can be revoked, and every view is written to the audit log. To fetch a report from a script:

curl -fsS -H "Authorization: Bearer $SPITFIRE_TOKEN" "https://spitfire.example.com/api/v1/runs/<run-id>/report?format=pdf&lang=tr" -o rapor.pdf
curl -fsS -H "Authorization: Bearer $SPITFIRE_TOKEN" "https://spitfire.example.com/api/v1/runs/<run-id>/export?kind=timeseries" -o saniyeler.csv

Failed request samples: the run page shows a few failed requests per step and outcome (target, error, failed check, a few response headers, the first 2 KB of the body; tokens in the address masked, no cookies). For a test whose responses must not be stored, errorSamples: off in its options 0.5.13+. Version history (test page): each save's diff with the current one, and restoring an old version as a new one 0.5.13+.

Scheduled runs and notifications 0.5.3+

On a test's page, Schedule: hourly, nightly, weekdays, weekly or any 5-field cron expression, in the time zone you pick. A schedule runs as the user who created it, with that user's current rights. At most every 5 minutes; a firing is skipped while the previous run still runs; one more than 10 minutes late (controller was down) is not caught up but marked missed. All of them are under Test → Schedules; "Run now" tries one immediately.

Notification channels

Integrations → Notification channels → Add channel, pick the kind:

  • Slack: create an Incoming Webhook in Slack and paste its URL. A coloured message: result, requests, p95, error rate, failed thresholds, a link to the run.
  • Microsoft Teams: in the channel create the Workflows → "Post to a channel when a webhook request is received" flow and paste its URL. The same facts in an adaptive card with an "Open the run" button.
  • E-mail: enter your server in the E-mail (SMTP) card on the same page (STARTTLS, TLS, or plain on a trusted network; the password is stored encrypted) and send a test mail. The finished run's PDF report is attached.

Each channel picks its events (run.started, run.finished, run.regressed, schedule.failed); with Only notify on problems passing runs and starts are not sent, while runs that break a threshold, fail or are stopped, and scheduled runs that did not start, are. A channel can be limited to certain tests. The message language (Turkish/English) is under Integrations → General. schedule.failed also reaches JSON webhooks, with run.testId and a schedule field.

Regressions against the baseline: when the test has a baseline run, every finished run is compared with it (p95, p99, error rate, requests/s; with the test's tolerances, or 5% warn / 15% fail). The message lists what got worse; a clear regression also sends run.regressed (off by default on new channels; a problem for channels that notify only on problems) 0.5.13+.

Single sign-on (OIDC, LDAP) 0.5.3+

In the UI, Single sign-on (admin). Set Spitfire's public address on the Integrations page first; the redirect URI is built from it.

OpenID Connect

Create a web application (authorization code) at the identity provider and add the redirect URI:

https://spitfire.example.com/api/v1/auth/oidc/callback
  • Entra ID: issuer https://login.microsoftonline.com/<tenant-id>/v2.0; for groups, turn on the "groups" claim in the app's token configuration (group object ids arrive).
  • Okta: issuer https://<domain>.okta.com (or an authorization server); define a "groups" claim for groups.
  • Keycloak: issuer https://<server>/realms/<realm>; add a "Group Membership" mapper to the client (claim name groups, full path off).
  • Google: issuer https://accounts.google.com; Google sends no groups, so limit by allowed domain.

Spitfire uses PKCE and a nonce and verifies the ID token's signature with the provider's keys; unverified e-mail addresses are refused. "Test discovery" finds the provider without saving.

LDAP / Active Directory

A server (ldaps://dc1.corp.local:636, or ldap:// with StartTLS), a service account that can read, and a user search base are enough. The default filter matches uid, sAMAccountName and mail; users type their directory user name or e-mail in the normal sign-in form. The password only goes to the directory and is not stored in Spitfire. Groups come from memberOf; for directories without it give a group search base. "Try" binds as a user and shows what Spitfire would do (access, role) without saving.

Accounts, roles, groups

Accounts can open on first sign-in (the license's user limit applies), or an admin creates the account on the Users page with sign-in method OIDC/LDAP. Admin groups decide the role at every sign-in; membership sync adds users to the Spitfire groups named like their provider groups and removes them when that stops (memberships added by hand are left alone). A password account with the same e-mail is never bound to an SSO identity on its own; an admin changes the account's sign-in method. With Password sign-in: admins only everyone else signs in with SSO, and an admin password stays as break-glass access.

Updating

Run the install command again (irm … | iex on Windows). The new release unpacks into the same folder; keys (deploy/*/.env or spitfire-secrets) and data are kept.

curl -fsSL https://spitfire.tr/install.sh | bash -s -- docker

When the version changes, the installer first dumps the database to ~/spitfire/backups/*.dump (newest 5; restore with pg_restore). Without a dump there is no update; --no-backup (Windows: -NoBackup) skips it 0.5.5+.

When a new release is out, the controller learns it from spitfire.tr once a day; admins see its notes and the command for this installation (folder or namespace included) at the top of the UI. Only the version number is sent; without internet access set SPITFIRE_UPDATE_CHECK=off. Every release: release notes 0.5.7+.

If an update goes wrong, ~/spitfire/install.sh docker --rollback (Kubernetes: kubernetes --rollback; Windows: -Rollback) first dumps the current database, then restores the newest dump and starts the release that took it. Changes made after that dump are lost; running it again undoes the rollback 0.5.10+.

The installed version, the last check's result and the command for this installation are always on the Version and updates card of the License page; Check now does not wait for the daily check. The release list is signed: a list whose signature does not hold is not shown 0.5.8+.

Behind a corporate proxy, run the install command with the proxy set; HTTPS_PROXY, HTTP_PROXY and NO_PROXY are passed on to the controller (the update check, webhooks, SSO). Add SSO or webhook addresses on the internal network to NO_PROXY 0.5.8+.

export HTTPS_PROXY=http://proxy.firma.local:3128 NO_PROXY=.firma.local && curl -fsSL https://spitfire.tr/install.sh | bash -s -- docker

Docker pulls images with the Docker service's proxy setting, not the shell's (Docker Desktop: Settings → Resources → Proxies; Linux: HTTPS_PROXY via systemctl edit docker). On Windows set $env:HTTPS_PROXY='http://proxy.firma.local:3128' first.

A specific release or another folder:

curl -fsSL https://spitfire.tr/install.sh | SPITFIRE_VERSION=0.5.13 SPITFIRE_DIR=/opt/spitfire bash -s -- docker

Back up the keys: without SPITFIRE_SECRET_KEY stored connection passwords cannot be read.

Removing

~/spitfire/uninstall.sh docker

With the data: --purge. For Kubernetes and runners use kubernetes / runner.

License

Spitfire runs without a license within the free edition's limits. Paste a license key on the License page in the UI; it is verified offline.

Data retention (License page): how many days per-second data and logs, runs (at least 7; baselines are never deleted) and the audit log (at least 30) are kept; what you leave empty stays forever. Before saving, the page shows what the next daily pass would delete 0.5.13+.