openstatus logoDashboard

How to Self-Host openstatus

Problem

You want to run openstatus on your own infrastructure instead of using the hosted service. This gives you full control over your data, customization options, and the ability to monitor internal services not accessible from the public internet.

Solution

openstatus provides a Docker Compose setup that makes self-hosting straightforward. This guide walks you through deploying all necessary services and configuring your self-hosted instance.

Only want the status page? If you already have monitoring elsewhere and just need somewhere to publish incidents, the lightweight status-page-only setup runs four services instead of the full stack — no Tinybird, no probes, no API server.

Prerequisites

  • Docker and Docker Compose installed
  • Basic understanding of Docker and containerization
  • Command line experience
  • Git installed
  • The Tinybird CLI (tb) — required for the analytics setup in Part 2

Two different things are called "private location"

This trips up almost everyone, so read this before you start. There are two separate components, with two confusingly similar image names:

ImageWhat it isWhere it runs
Probeghcr.io/openstatushq/private-location:latestRuns the actual checks against your endpoints and ships results to the ingest serverAnywhere you want to monitor from — a VPS, a Raspberry Pi, inside your VPC. Not part of docker-compose.yaml.
Ingest serverghcr.io/openstatushq/openstatus-private-location:latestReceives results from probes, writes them to Tinybird, and forwards status changes to the workflows appOn your openstatus host, as the private-location service in docker-compose.yaml (published on port 8081)

The probe's OPENSTATUS_INGEST_URL points at the ingest server — that is port 8081, not 3001. Port 3001 is the API server (apps/server) and has nothing to do with ingest.

Caution

We know the naming is bad. The prefixed image (openstatus-private-location) is the server; the unprefixed one (private-location) is the probe.

Known limitations

Self-hosting openstatus currently has these constraints:

  • It only works with private locations. You have to deploy our probes to the cloud provider of your choice.
  • The AI assistant's docs knowledge base (the search_docs and get_doc_page tools) queries the public https://www.openstatus.dev docs by default. Set OPENSTATUS_WEB_BASE_URL if you mirror the marketing site and want the assistant to read your own copy.
  • IP Restriction is not secure outside Vercel. The IP restriction feature for status pages relies on the X-Forwarded-For header, which Vercel overwrites with the verified client IP. When self-hosting behind a different reverse proxy, clients can spoof this header to bypass IP restrictions. If you self-host and use IP restriction, ensure your reverse proxy strips and rewrites X-Forwarded-For with the real client IP before forwarding to openstatus.

Step-by-step guide

This guide is divided into three parts: launching the services, setting up analytics, and configuring the application through the UI.

Part 1: initial setup and service launch

  1. Clone the repository

    Get the latest version of openstatus:

    git clone https://github.com/openstatushq/openstatus
    cd openstatus
    
  2. Configure your environment

    Copy the example environment file. This file will hold all your configuration variables.

    cp .env.docker.example .env.docker
    

    Open .env.docker in a text editor. Four values are marked [REQUIRED] and must be set: AUTH_SECRET, RESEND_API_KEY (magic-link login won't work without it), SELF_HOST, and NEXT_PUBLIC_URL. For a complete setup, review the file for other variables like OAuth providers or email services.

    Two values matter for the rest of this guide — set them now:

    # Point the apps at the local Tinybird container instead of Tinybird Cloud.
    # If you leave this empty, the dashboard queries api.tinybird.co and every
    # chart fails with "the pipe ... does not exist".
    TINYBIRD_URL=http://tinybird-local:7181
    
    # Shared secret the ingest server uses to authenticate to the workflows app.
    # Any random string, but it must be set on both.
    CRON_SECRET=some-random-string
    

    You'll fill in the Tinybird tokens in Part 2, once the container is running.

  3. Build and start services

    Use Docker Compose to build and run all openstatus services in the background.

    export DOCKER_BUILDKIT=1
    docker compose up -d
    

    You can check the status of the services with docker compose ps. It might take a few minutes for all services to be healthy.

    Database migrations run automatically: the db-migrate one-shot container applies them before the apps start, and every other service waits on it. It's idempotent, so it's safe to leave in place on each docker compose up. If it fails, see Troubleshooting.

    Tip

    Prefer prebuilt images over building from source? Use docker compose -f docker-compose.github-packages.yaml up -d, which pulls from ghcr.io/openstatushq/* instead.

Part 2: analytics setup

openstatus stores every check result in Tinybird. Without a working Tinybird setup you can create monitors and probes, but the dashboard will show no data.

This is the step most self-hosters get wrong, so follow it exactly.

  1. Deploy the Tinybird project

    Deploy the datasources, pipes, and endpoints into the local Tinybird container started by Compose:

    cd packages/tinybird
    tb --local deploy
    
  2. Confirm the deployment is live

    A deployment that stays in Staging is not queryable — the pipes exist but every request 404s. Check it:

    tb --local deployment ls
    

    You want Status to read Live:

    -------------------------------------
    | ID | Status | Created at          |
    -------------------------------------
    |  1 | Live   | 2026-07-25 18:20:52 |
    -------------------------------------
    

    If it says Staging, promote it:

    tb --local deployment promote
    
  3. Get the Tinybird token

    You do not need to log in to Tinybird Cloud. Against a local container the CLI runs in a temporary local workspace and prints its token:

    tb --local info
    

    Copy the value of token from the Tinybird Local section.

    To grab it in one line:

    tb --local info | grep '^token:' | awk '{print $2}'
    

    Don't use curl http://localhost:7181/tokens

    That endpoint works without auth and looks like the right answer, but it returns tokens for a different workspace than the one tb --local deploy deploys into. Ingest will appear to succeed — Tinybird silently auto-creates a datasource from the first event — while every dashboard query fails with The pipe 'endpoint__http_metrics_1d__v1' does not exist. Use tb --local info.

    Likewise, tb --local open asks you to sign in to Tinybird Cloud and is not usable on a headless server. You don't need it.

  4. Add the token to your environment

    The Node apps and the Go ingest server read different variable names for the same token. Both are in .env.docker — fill them in with the same value:

    # Read by the dashboard, status page, server, and workflows apps
    TINY_BIRD_API_KEY="your-tinybird-local-token"
    
    # Read by the private-location ingest server (Go)
    TINYBIRD_TOKEN="your-tinybird-local-token"
    

    Caution

    Setting only TINY_BIRD_API_KEY is the most common self-hosting mistake. The ingest server never reads it, so every check it receives is rejected by Tinybird with unexpected status code: 403 and no data ever reaches your dashboard.

    Restart so the new values are picked up:

    cd ../..
    docker compose up -d
    
  5. Verify analytics end to end

    Confirm the token can query a deployed pipe:

    TOKEN=$(tb --local info | grep '^token:' | awk '{print $2}')
    
    curl -s -o /dev/null -w '%{http_code}\n' \
      -H "Authorization: Bearer $TOKEN" \
      "http://localhost:7181/v0/pipes/endpoint__http_metrics_1d__v1.json?monitorId=1"
    

    A 200 means Part 2 is done. A 404 means the deployment isn't promoted or you're using the wrong token — go back to steps 5 and 6.

Part 3: application configuration

Now that the services are running, you can access the dashboard and perform the final setup steps.

  • Dashboard: http://localhost:3002
  • Status Pages: http://localhost:3003
  1. Create a workspace and set limits

    • Navigate to the dashboard at http://localhost:3002.
    • Sign up and create a new workspace.
    • Because this is a self-hosted instance, you need to manually set the feature limits for your workspace directly in the database.

    The following command updates the limits for the workspace with id = 1. If your workspace has a different ID, change the WHERE id = 1 part of the command.

    curl -X POST http://localhost:8080/ -H "Content-Type: application/json" \
      -d '{"statements":["UPDATE workspace SET limits = '\''{\\"monitors\\":100,\\"periodicity\\":[\\"30s\\",\\"1m\\",\\"5m\\",\\"10m\\",\\"30m\\",\\"1h\\"],\\"multi-region\\":true,\\"data-retention\\":\\"24 months\\",\\"status-pages\\":20,\\"maintenance\\":true,\\"status-subscribers\\":true,\\"custom-domain\\":true,\\"password-protection\\":true,\\"white-label\\":true,\\"notifications\\":true,\\"sms\\":true,\\"pagerduty\\":true,\\"notification-channels\\":50,\\"members\\":\\"Unlimited\\",\\"audit-log\\":true,\\"private-locations\\":true}'\'' WHERE id = 1"]}'
    

    You can find your workspace ID by inspecting the database with a command like curl -X POST http://localhost:8080/ -H "Content-Type: application/json" -d '{"statements":["SELECT id, name FROM workspace"]}'.

    If you want to unlock the paid features, you need to upgrade your workspace inside the database. The following command assumes that you want to change the payment plan for the workspace with the ID of 1, and that you want to change it to a "Pro" instance indefinitely.

    curl -sS -X POST "http://localhost:8080/" \
      -H "Content-Type: application/json" \
      -d "{\"statements\":[
        \"UPDATE workspace SET plan='team', paid_until=strftime('%s','now') + 315360000, ends_at=NULL WHERE id=1;\",
        \"SELECT id, plan, paid_until, ends_at FROM workspace WHERE id=1;\"
      ]}"
    
  2. Deploy a probe

    The self-hosted version relies on private locations to perform checks. The ingest server is already running from Part 1 — what you deploy here is the probe.

    • In the dashboard, navigate to Settings → Private Locations and create a new one.
    • Copy the generated key.
    • Run the probe wherever you want to check from:
    docker run -d --name openstatus-probe \
      -e OPENSTATUS_KEY=<your-key> \
      -e OPENSTATUS_INGEST_URL=http://<your-server-ip-or-domain>:8081 \
      ghcr.io/openstatushq/private-location:latest
    

    The two environment variables are:

    • OPENSTATUS_KEY — the key you copied from the dashboard. Some older docs call this OPENSTATUS_TOKEN; that name is not read by the probe.
    • OPENSTATUS_INGEST_URL — the URL of your ingest server, i.e. the private-location service on port 8081. If you don't set it, the probe defaults to openstatus Cloud (https://openstatus-private-location.fly.dev) and your self-hosted instance will never see a check.

    If you run the probe on the same Docker network as the rest of the stack, use the internal address instead — http://private-location:8080.

    A healthy probe logs like this:

    Launching openstatus private location checker
    2026/07/25 09:50:50 Starting job for monitor 1 (https://google.com)
    2026/07/25 09:50:50 Monitor check for 1 (https://google.com) ingested with status "success" (code 200)
    

    Tip

    The probe refreshes its monitor list every 10 minutes. After creating or editing a monitor, give it up to 10 minutes — or restart the probe — before assuming something is broken.

  3. Create monitors

    You're all set! You can now create monitors in the dashboard. They will be checked by the private location you deployed.

openstatus running locally with self-hosted services

Part 4: scheduled tasks

Self-hosted deployments need external cron scheduling for background tasks. Without these, critical features like private location health monitoring won't work.

  1. Set up the private location health cron

    The private-location-health cron job monitors your private location probes and sends email notifications when they go offline or recover.

    What it does:

    • Checks if private location agents have reported recently (within the last 15 minutes)
    • Transitions status from activeerror when an agent stops reporting
    • Transitions status from erroractive when an agent resumes reporting
    • Sends email notifications to workspace members on status transitions
    • Creates audit log entries for status changes

    Without this cron, private location status will never update from "error" to "active", and recovery notifications will not be sent.

    Setup with system cron (Linux/Unix):

    Add this to your crontab (crontab -e):

    # Run private location health check every 5 minutes
    */5 * * * * curl -sS -H "Authorization: YOUR_CRON_SECRET" http://localhost:3000/cron/private-location-health
    

    Replace YOUR_CRON_SECRET with the value you set in .env.docker (step 2), and adjust the URL if your workflows service runs on a different host or port.

    Setup with Docker cron container:

    Create a cron/ directory in your openstatus folder:

    mkdir cron
    

    Create cron/Dockerfile:

    FROM alpine:latest
    RUN apk add --no-cache curl
    COPY crontab /etc/crontabs/root
    CMD ["crond", "-f", "-l", "2"]
    

    Create cron/crontab:

    */5 * * * * curl -sS -H "Authorization: ${CRON_SECRET}" http://workflows:3000/cron/private-location-health
    

    Add to your docker-compose.yaml:

    services:
      # ... existing services ...
    
      cron:
        build: ./cron
        container_name: openstatus-cron
        env_file:
          - .env.docker
        networks:
          - openstatus
        restart: unless-stopped
    

    Then restart your stack:

    docker compose up -d
    

    Verification:

    Check the workflows logs to confirm the cron is running:

    docker compose logs workflows | grep "private-location-health"
    

    You should see entries every 5 minutes like:

    private-location-health complete: checked=2 toError=0 toActive=0
    

    Caution

    If you skip this step, your private locations will show "error" status permanently even when actively reporting.

Configuring the AI assistant (optional)

openstatus ships an in-dashboard AI assistant. It is off by default when self-hosting — the chat endpoint returns 503 "Chat is not configured" until you point it at a model provider. Configure one of the two options below in your .env.docker (root) — or apps/dashboard/.env for a manual setup — then restart the dashboard.

Option 1: OpenAI-compatible endpoint (bring your own model)

Use any OpenAI-compatible API — NVIDIA NIM, vLLM, Ollama, OpenRouter, Neon AI Gateway, LM Studio, or a private gateway. This is the recommended path for self-hosting: the model and key stay on your own infrastructure.

AI_BASE_URL=https://integrate.api.nvidia.com/v1
AI_API_KEY=nvapi-xxxxx
AI_MODEL=meta/llama-3.1-70b-instruct
  • AI_BASE_URL — the provider's OpenAI-compatible base URL (usually ends in /v1). Setting it enables this option.
  • AI_API_KEY — optional. Leave empty for keyless local gateways such as Ollama.
  • AI_MODEL — the model id to use. It must support tool / function calling — the assistant manages your monitors and status pages through tools, so a model without tool support cannot act.

A fully local, keyless setup with Ollama:

AI_BASE_URL=http://localhost:11434/v1
AI_MODEL=llama3.1

Example: Neon AI Gateway

Neon AI Gateway serves an OpenAI-compatible API, so it goes through the same three variables. Every Neon branch has its own gateway host, which the Neon console shows as NEON_AI_GATEWAY_BASE_URL next to a NEON_AI_GATEWAY_TOKEN credential. The host is bare, so add /v1 when you copy it, and give the credential the ai_gateway:invoke scope.

AI_BASE_URL=<NEON_AI_GATEWAY_BASE_URL>/v1
AI_API_KEY=<NEON_AI_GATEWAY_TOKEN>
AI_MODEL=gpt-5-mini

Any entry in the Neon model catalog that lists chat/completions among its endpoints works as AI_MODEL, and the gpt-5-mini above is one example rather than a default. Pick a model that supports tool calling. Neon does not record that per model, so check the flag on Models.dev first. This path has not been validated through a complete assistant workflow, so exercise a real monitor or status-page change before you depend on it.

Note

AI Gateway is in beta, requires a paid Neon plan, and runs only in AWS US East (Ohio) (aws-us-east-2).

Option 2: Vercel AI Gateway

If you use the Vercel AI Gateway, set a single key. The model is chosen automatically (a smaller model for free workspaces, a stronger one for paid).

AI_GATEWAY_API_KEY=your-gateway-key

When both are configured, AI_BASE_URL takes priority over the gateway.

Service architecture

openstatus consists of multiple services running together:

ServicePortPurpose
db-migrateOne-shot database migrations; exits after applying
workflows3000Background jobs and scheduled tasks
server3001API backend (ConnectRPC + REST v1)
dashboard3002Admin interface for configuration
status-page3003Public status pages
private-location8081Ingest server — receives results from probes
libsql8080Database (HTTP)
libsql5001Database (gRPC)
tinybird-local7181Analytics and metrics

The probe is not in this table — it runs outside the stack, wherever you want to check from, and talks to private-location on port 8081.

What you've accomplished

  • Deployed openstatus on your own infrastructure
  • Configured all required services
  • Set up a private location for monitoring
  • Created your first self-hosted monitor

Troubleshooting

Containers won't start — check Docker logs with docker compose logs [service-name].

Database migrations fail — inspect docker compose logs db-migrate. To re-run them by hand without installing anything on the host (the image's entrypoint is the migration itself):

docker run --rm -it \
  --network openstatus \
  --env-file .env.docker \
  -e DATABASE_URL=http://libsql:8080 \
  openstatus/db-migrate:latest

Or simply docker compose up -d db-migrate, since the step is idempotent.

Dashboard charts are empty and the log shows The pipe 'endpoint__http_metrics_1d__v1' does not exist — the dashboard is querying the wrong Tinybird workspace. Three causes, in order of likelihood:

  1. TINYBIRD_URL is unset, so it's querying Tinybird Cloud. Set TINYBIRD_URL=http://tinybird-local:7181.
  2. The token came from curl http://localhost:7181/tokens instead of tb --local info — wrong workspace. See step 6.
  3. The deployment is still in Staging. Run tb --local deployment promote.

Ingest server logs tinybird.success=false error.message="unexpected status code: 403"TINYBIRD_TOKEN is missing or wrong. It is a separate variable from TINY_BIRD_API_KEY; both must be set to the same token. See step 7.

Ingest server logs failed to forward status update to workflows ... 401 — the CRON_SECRET the ingest server sends doesn't match the one the workflows app expects. Make sure CRON_SECRET is set in .env.docker and that both containers were restarted afterwards. If the ingest server runs outside the Compose network, also confirm WORKFLOWS_URL points at your own workflows app — unset, it defaults to openstatus Cloud, which will reject your secret.

Private location shows an error state in the dashboard but the probe logs look fine — a cron in the workflows app marks a location unhealthy when it hasn't reported recently. Confirm workflows is running and healthy (docker compose ps workflows), and that the ingest server can reach it.

Probe runs but nothing appears in the dashboard — check, in order:

  1. OPENSTATUS_INGEST_URL points at port 8081 (the ingest server), not 3001.
  2. OPENSTATUS_KEY matches the key shown in Settings → Private Locations.
  3. The ingest server logs show IngestHTTP requests arriving — docker compose logs private-location.
  4. Those log lines show tinybird.success=true. If not, revisit step 7.

What's next

Learn more