AlertLoop is a self-hosted event, incident, and business notification center for software teams and operations-heavy businesses.
It helps applications send important technical incidents and business events into one reliable place, track their status, and deliver them through channels such as email, Telegram, and webhooks. Planned paid editions will add channels such as WhatsApp.
AlertLoop starts as a self-hosted product. Cloud SaaS is intentionally deferred until the core product and market positioning are validated.
- Community (this repository): free, self-hosted, backend API, Swagger/OpenAPI, basic events list page, Email/Telegram/Webhook delivery, routing rules, Telegram delivery through a proxy, delivery retries and dead-letter replay, SQLite and PostgreSQL.
- Pro Self-hosted (planned, paid): multi-project, SDKs, WhatsApp, RBAC, retention policies (per-project and per-event-type rules managed from the UI), escalation policies.
- Enterprise (planned, paid): on-prem license, SSO, HA, audit, custom adapters, support.
- Events API with API-key auth and OpenAPI/Swagger.
- Three event families:
incident,business_event,audit. - Event lifecycle:
new,acknowledged,resolved,muted,escalated(with a manualescalateaction). - Idempotent ingestion via
dedupe_key. - Incident lifecycle: a monitoring
source reports
status: firingwhile a problem lasts andstatus: resolvedwhen it ends; repeats update the open incident instead of notifying again, and a recovery closes it automatically. - Recovery notifications: closing an incident tells the channels that were told about it, with how long the outage lasted. On by default.
- Monit integration: watch processes, ports, filesystems, load, workers, and cron jobs on a Linux host, and have every finding become an incident that opens and closes on its own.
- Delivery channels: Email (SMTP), Telegram, Webhook (HMAC-signed).
- Routing rules: send each event to the channels that should get it (by type, severity, source, or category), with a dry-run preview endpoint.
- Telegram from a restricted network: a
per-channel HTTP/SOCKS5
proxy, or anapi_basemirror. - Delivery worker with retries, exponential backoff, dead-letter, and replay.
- Delivery attempt history, separate from event state.
- Three simple built-in web pages protected by an admin token: events list
(
/events), event detail (/events/{id}), and deliveries (/deliveries) — next to the richer React admin console at/admin. - Cursor-paginated list endpoints.
- Structured logs (text or JSON), to stdout or a file.
- Event retention with automatic cleanup (30 days by default, configurable via
retention_days). - Health probes under both names:
/healthand/health/live,/readyand/health/ready.
Operating it — backup and restore, upgrades, and runbooks for the failures that actually happen — is in OPERATIONS.md. Reporting a vulnerability is in SECURITY.md.
- Go: 1.25 or newer — only to build from source. Release binaries are static and need no runtime; there is no CGO dependency.
- SQLite: nothing to install — it is embedded (pure-Go driver). This is the default and needs no decision.
- PostgreSQL, only if you choose it: 12 or newer (14+ recommended, tested on
16). Uses
JSONB, partial indexes, andFOR UPDATE SKIP LOCKED. - Docker (optional): any recent Docker Engine with Compose v2 for the container deployment path.
There is one AlertLoop and one configuration file. What differs between the paths below is only how the process is supervised — and the database is a single line in that file, not a separate flavour of the product.
Pick by what you are doing:
| You want to… | Path | Database |
|---|---|---|
| see what it is, in a minute | Docker Compose demo | SQLite, thrown away with the volume |
| run it on a server, no Docker | prebuilt binary + systemd | SQLite by default |
| run it on a server, with Docker | Compose + PostgreSQL profile | PostgreSQL |
| split API and workers | Compose + PostgreSQL profile | PostgreSQL (required) |
cp .env.example .env # presets COMPOSE_PROFILES=demo
docker compose up -dBoth deployments live in the single docker-compose.yml at the repository root,
selected by profile — demo here, postgres below. Switch by editing
COMPOSE_PROFILES in .env; do not leave it set and add --profile as well,
because Compose combines the two and would start both on the same port. With no
.env at all, docker compose --profile demo up -d selects one directly.
The images are pulled from GHCR; nothing is built from the compose file.
Then open:
- Admin console: http://localhost:8080/admin
- API docs: http://localhost:8080/swagger
- Events page: http://localhost:8080/events?token=change-me-admin (admin token)
Nothing is configured yet, so events are stored and delivered nowhere — which is a valid way to run. Add channels when you want notifications.
Download a release binary (linux/amd64, linux/arm64; darwin/windows for local evaluation) and give it a config file:
cp alertloop.example.yaml alertloop.yaml # edit: admin_token, channels
./alertloop --config alertloop.yaml alldeploy/systemd/install.sh turns that into a supervised service (unit file,
alertloop user, /etc/alertloop/alertloop.yaml, /var/lib/alertloop). The
binary is static and CGO-free, so there is nothing else to install — SQLite is
embedded.
cp .env.example .env # then edit it:
# COMPOSE_PROFILES=postgres
# ALERTLOOP_ADMIN_TOKEN=$(openssl rand -hex 32)
# POSTGRES_PASSWORD=$(openssl rand -base64 32)
cp alertloop.example.yaml alertloop.yaml # edit: channels, routing
docker compose up -dCreate alertloop.yaml before starting. Docker creates a directory in place
of a bind mount whose source is missing, and AlertLoop then fails on "is a
directory".
This profile runs the API and the delivery worker as separate containers against
a PostgreSQL container, with one alertloop.yaml mounted into both — so the
channel and routing configuration can never drift between them. The database
driver and DSN reach that file from the environment, which is why the same
example config serves this profile and a binary install.
It refuses to start without ALERTLOOP_ADMIN_TOKEN and POSTGRES_PASSWORD
rather than falling back to placeholders published in this repository. Pin
ALERTLOOP_IMAGE to a version tag in production: latest moves under you on
the next docker compose pull.
make run # build + run all-in-one on :8080 (SQLite)
make test # run the test suiteThe API accepts your admin token (full access) or a scoped API key. For a quick test, use the admin token from your config:
curl -X POST http://localhost:8080/v1/events \
-H "X-API-Key: change-me-admin" \
-H "Content-Type: application/json" \
-d '{
"type": "incident",
"severity": "critical",
"source": "feeds_worker",
"message": "Feed processing failed",
"dedupe_key": "feeds:developer:15:flats"
}'For real integrations, create least-privilege API keys with a scope
(ingest for event sources, read for dashboards, full for trusted tools) in
the config file — see alertloop.example.yaml.
A monitoring source does not send one-off notifications. It reports that a
problem is happening, keeps reporting it while it lasts, and reports when it
is over. Add status to say so:
# The problem starts. Creates an incident and notifies. -> 201
curl -X POST http://localhost:8080/v1/events \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"status":"firing","type":"incident","severity":"critical","source":"monit",
"message":"PostgreSQL does not respond on port 5432",
"dedupe_key":"server-01:postgresql:availability"}'
# Still broken, reported every minute. Updates the SAME incident with the newest
# severity and message. No second incident, and nobody is notified again. -> 200
# (identical request)
# Fixed. Closes the incident and records when. -> 200
curl -X POST http://localhost:8080/v1/events \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"status":"resolved","dedupe_key":"server-01:postgresql:availability"}'Rules worth knowing:
-
dedupe_keyis the incident's identity. Keep it stable for one logical check —host:service:check. Never put a timestamp, a random id, a measured value, or varying error text in it. -
Resolving frees the key. The same failure happening again opens a new incident and notifies again, which is what you want from a service that flaps.
-
A recovery only needs
dedupe_key. It identifies an incident to close; it does not describe a new event. -
A recovery for something unknown is a success, not an error. It returns
204when no event has ever carried that key — the source may be reporting a recovery after retention removed the incident, or after restarting without having seen the failure. -
Repeats do not re-notify. Refreshing an incident creates no delivery attempts. A check that fails every minute must not page anyone every minute.
-
An acknowledged incident stays acknowledged while it keeps firing.
-
Closing an incident sends a recovery notice to the channels that received the alert, and to no others:
[RESOLVED] PostgreSQL does not respond on port 5432 Key: server-01:postgresql:availability Started: 2026-08-22 03:14:07 UTC Resolved: 2026-08-22 03:26:37 UTC Duration: 12m30sResolving is deliberately not re-routed: routing ran once at ingestion, and the audience for "it is fixed" is the audience of "it is broken". A channel whose alert dead-lettered is skipped — it never learned there was a problem. Repeating a recovery does not notify twice. The manual resolve action in the console notifies too. Turn it all off with
notify_on_resolve: false.
Without status, nothing changes from earlier versions. dedupe_key remains
a plain idempotency key: a repeat returns the stored event untouched. The two
readings of that field are now explicit rather than conflated.
The Monit integration connects AlertLoop to Monit, which watches processes, ports, filesystems, memory, load, workers, and cron jobs:
cd integrations/monit
sudo ./install.sh --with-examplesMonit detects; AlertLoop receives, deduplicates, routes, and delivers. AlertLoop does not grow its own server monitoring, and Monit runs on the host rather than in a container — it has to see host processes and real filesystems, and it has to be able to notice that Docker itself has died.
It ships eleven example rules, install/uninstall scripts, wrappers for cron
jobs and worker heartbeats, and a README that is explicit about what it cannot
do. It is Community, like every inbound integration: what stays paid is the
policy layer on top — escalation policies, on-call schedules, per-project
routing.
The single alertloop binary runs in three modes:
server— HTTP API and web UI.worker— delivery workers and retention cleanup.all— both in one process (default; ideal for small installs).
AlertLoop ships a React admin console (Overview, Events, Event detail,
Deliveries with dead-letter replay, About). It is embedded in the binary and
served at /admin on the same origin as the API — nothing extra to install or
run. With either deployment path you simply open /admin — but where it is
reachable from differs, because the two paths bind the port differently:
- Local (either path): http://localhost:8080/admin.
- Binary / systemd on a server: AlertLoop listens on
:8080on all interfaces, so it is also reachable athttp://<server-ip>:8080/admin. - Docker Compose on a server: both bundled Compose files publish the port on
127.0.0.1only, sohttp://<server-ip>:8080/adminis refused by design — a published Docker port is not filtered by a host firewall such as ufw, so binding0.0.0.0would silently expose the console. Reach it through the reverse proxy indeploy/proxy/, through an SSH tunnel (ssh -L 8080:127.0.0.1:8080 user@server, then use the Local URL above), or by deliberately changing the mapping to"8080:8080"if you accept the exposure.
For a real domain with HTTPS (the recommended setup for either path), see Access over a domain.
Sign in with the admin token from your config (there are no user accounts in Community). The token is kept in the browser's session storage and sent to the API. Because the console is same-origin with the API, it works at any host or domain with no rebuild and no CORS configuration.
The console has a light responsive layout: on phones the sidebar collapses into a menu and wide tables scroll horizontally. A full mobile experience is not a Community goal — use a desktop browser for day-to-day work.
cd web/admin
npm install
npm run dev # http://localhost:5273, proxies /v1 to localhost:8080Build it into the binary with make admin (outputs to internal/adminui/dist,
which the Go build embeds). The Docker image build does this automatically, so
go build and docker build both produce a binary/image with the console
already inside.
Where AlertLoop is reachable before you add a proxy depends on how you run it:
- Binary / systemd: the process listens on
:8080on all interfaces, so on a server it is reachable athttp://<server-ip>:8080(restrict it with your host firewall, or bind it to loopback withaddr: "127.0.0.1:8080"). - Docker Compose: both bundled Compose files publish the port as
127.0.0.1:8080:8080, so the container is reachable from the host only. That is deliberate: a published Docker port is not filtered by ufw/firewalld, so binding0.0.0.0would put the admin console on the public internet without the host firewall noticing.
Either way, for a real domain put an HTTPS reverse proxy in front — AlertLoop itself speaks plain HTTP and does not terminate TLS. Since the API, admin console, and Swagger are all one origin, a single proxy rule covers everything:
[browser] --HTTPS--> [nginx/Apache :443] --HTTP--> [alertloop 127.0.0.1:8080]
Then everything is on your domain, same-origin, no CORS:
https://alerts.example.com/admin— admin consolehttps://alerts.example.com/v1/...— APIhttps://alerts.example.com/swagger— API docs
Ready-to-adapt configs are in deploy/proxy/:
- nginx —
deploy/proxy/nginx.conf - Apache —
deploy/proxy/apache.conf
Both redirect HTTP→HTTPS and forward to 127.0.0.1:8080 — which is exactly what
the Compose port mapping publishes, so the proxy path needs no Compose changes.
Get a certificate with Let's Encrypt (certbot). HTTPS is required in
production — the admin token travels with each request and must not go over
plaintext HTTP.
The database is one line of configuration, not a different edition of the product. Both drivers run the same code, the same migrations, and the same queue; they differ in what they let you do around it.
SQLite — the default. Nothing to install: it is embedded (pure Go, no CGO),
and AlertLoop creates the file and its tables on first run. Right for a
single-host install, which is most installs. :memory: works for throwaway runs.
database:
driver: sqlite
dsn: alertloop.dbPostgreSQL — when you need what SQLite cannot give: the API and the delivery worker as separate processes (they share the queue through the database), a database you already operate and back up, or room to grow.
database:
driver: postgres
dsn: "postgres://USER:PASSWORD@HOST:5432/alertloop?sslmode=require"The DSN carries a password, so it is a good candidate for ${VAR} — see
Configuration. Use sslmode=require for a remote database;
sslmode=disable only on a local or private-network Postgres.
AlertLoop connects to an existing database and creates only its own tables
(events, delivery_attempts, schema_migrations) through migrations that run
at startup. It does not create the database itself — create it once:
createdb -U postgres alertloop
# or: psql -U postgres -c "CREATE DATABASE alertloop;"Sharing that database with another application is fine: AlertLoop never touches
tables that are not its own. Just make sure nothing else owns tables named
events, delivery_attempts, or schema_migrations. (A configurable table
prefix is on the roadmap for shared databases.)
Switching later means changing those two lines and starting with an empty history: there is no migration path between the two engines, and event history is not carried over.
AlertLoop writes structured logs. By default they go to stdout; set a file path to also persist them for external tools.
log:
level: "info" # debug | info | warn | error
format: "text" # text (human-readable) | json (for Loki/Elasticsearch/Vector)
file: "" # empty = stdout; e.g. /var/log/alertloop/alertloop.logHow to read logs by deployment:
- Binary (stdout): run in a terminal, or redirect:
./alertloop all >> alertloop.log 2>&1. - Binary (file): set
log.fileand read it with any tool:tail -f /var/log/alertloop/alertloop.log. - systemd: logs go to the journal —
journalctl -u alertloop -f. Setformat: jsonfor machine-readable output that log shippers can parse. - Docker:
docker compose logs -f alertloop(ordocker logs).
For centralized logging, set format: json and point your log collector at the
file or the container's stdout.
One YAML file configures everything, on top of built-in defaults. Point the
binary at it with --config /path/to/alertloop.yaml (or ALERTLOOP_CONFIG).
See alertloop.example.yaml for the full list of settings.
The environment is not a second place to configure AlertLoop — it exists to keep
secrets out of the file. Any value written as exactly ${VAR} or
${VAR:-default} is replaced from the environment at startup:
admin_token: ${ALERTLOOP_ADMIN_TOKEN}
channels:
telegram:
- name: alerts
bot_token: ${TELEGRAM_BOT_TOKEN}
chat_id: "-1001234567890"
database:
dsn: ${ALERTLOOP_DB_DSN:-alertloop.db}Rules, and there are only three:
- The whole value, or nothing.
${VAR}inside a longer string is left alone — that way a password containing a literal$is never mangled. - A missing variable stops the process, unless the reference carries a
:-default. An emptyadmin_tokencaused by a typo in a variable name would leave the API open, so it is refused instead. - The substituted text is data, not YAML. A password containing
:or#stays a password.
Everything else — channels, API keys, routing, worker and rate-limit tuning — lives in the file only.
Upgrading from 0.2.x: the
ALERTLOOP_ADDR,ALERTLOOP_DB_DSN,ALERTLOOP_LOG_*,ALERTLOOP_WORKER_*, and related variables no longer configure anything. If one is still set, AlertLoop refuses to start and names the config line to write instead — ignoring them could leave a process running on a database its operator did not choose.
You can run AlertLoop with no channels configured — it will accept and store
events (visible in the API and at /admin) and simply deliver nothing. This is
the default in the example configs, so the app starts out of the box. To send
notifications, configure one or more channels (email/telegram/webhook) in the
YAML file. If you enable a channel you must fill in all of its required fields,
or startup fails with a clear message (this catches typos like a missing
bot_token).
With no routing section configured, every event is delivered to every
configured channel. To split events between audiences, see
Routing rules.
One AlertLoop instance usually serves more than one audience: the customer who
only wants orders, and the developer who also wants the technical failures. The
optional routing section decides which channels each event goes to.
routing:
# Rules are checked top to bottom; the FIRST match wins.
rules:
- name: silence-healthchecks
match:
source: [healthcheck]
channels: [] # explicit "nowhere": stored, never delivered
- name: incidents-to-dev
match:
type: [incident]
channels: [dev-telegram, dev-email]
- name: orders-to-customer
match:
type: [business_event]
category: ["order.*"]
channels: [customer-telegram]
# Where events that matched no rule go.
default: [dev-telegram]Conditions inside match:
| Field | Matches | Format |
|---|---|---|
type |
event family | list of incident, business_event, audit |
severity |
severity | list of info, success, warning, error, critical |
min_severity |
severity | one value; matches that level and above |
source |
event source | list; a trailing * is a prefix wildcard |
category |
event category | list; a trailing * is a prefix wildcard |
Rules of the road:
- Values inside one field are OR-ed; different fields are AND-ed. An omitted
field constrains nothing, and a rule with no
matchat all is a catch-all. - Comparison ignores case and surrounding whitespace.
- Only a trailing
*is supported, and only forsourceandcategory:order.*matchesorder.created. A*anywhere else is a configuration error — there are no regular expressions. min_severityranks areinfo= 10,success= 10,warning= 20,error= 30,critical= 40.successdeliberately sharesinfo's rank: this is an order of alarm, not of importance, and a successful outcome is not more alarming than a notice.- The first matching rule wins and later rules are not consulted. Channel lists from several rules are never merged — at three in the morning, predictability beats expressiveness.
channels: []is deliberate suppression: the event is stored (and visible in the API and the console) but nothing is delivered.defaultapplies only when no rule matched. Without it, unmatched events are delivered nowhere and each one is logged atwarnlevel with its id, type, severity, source, and category.
The startup log prints the resolved table, plus warnings for the two mistakes that are otherwise invisible: rules that can never match because a catch-all sits above them, and configured channels that no rule and no default sends to. A rule naming a channel that does not exist stops the process.
To check rules against a live instance without raising a false incident, use the
preview endpoint (full scope, creates nothing):
curl -X POST http://localhost:8080/v1/routing/preview \
-H "X-API-Key: change-me-admin" -H "Content-Type: application/json" \
-d '{"type":"business_event","source":"shop","category":"order.created"}'
# {"matched_rule":"orders-to-customer","channels":["customer-telegram"]}GET /v1/routing returns the whole table as it took effect.
Omitting the routing section keeps the pre-0.2.0 behavior: every event goes
to every configured channel. Upgrading from 0.1.1 needs no configuration change.
Some hosts cannot reach api.telegram.org directly. Two independent ways out,
both per channel:
channels:
telegram:
- name: dev-alerts
bot_token: "123456:ABC-DEF"
chat_id: "-1001234567890"
proxy: "socks5://user:pass@127.0.0.1:1080" # or http://, https://
- name: customer-alerts
bot_token: "123456:ABC-DEF"
chat_id: "-1009876543210"
api_base: "https://tg-mirror.example.com" # a trusted Bot API mirrorproxy— when you have your own HTTP or SOCKS5 proxy and the traffic should go through it. Supported schemes:http,https,socks5, andsocks5h. Anything else stops the process at startup with a clear message, rather than quietly sending nothing.api_base— when you have a trusted mirror or reverse proxy in front of the Bot API.
Notes:
- MTProto proxies do not work for the Bot API. They speak Telegram's client
protocol, not HTTP; pointing
proxyat one produces an obscure network error. Use an HTTP/SOCKS5 proxy or a mirror instead. socks5andsocks5hbehave identically here, and the difference people expect does not apply: Go's HTTP transport sends the target host name to the proxy (SOCKS5 address type0x03), so the proxy resolves DNS, not AlertLoop. Verified by a test with a local SOCKS5 server (TestTelegramSendsThroughSOCKS5Proxy), not by assumption.- The setting is per channel, not per process, so a Telegram channel can use a proxy while a webhook into your internal network stays direct.
- With
proxyunset, the process-wideHTTP_PROXY/HTTPS_PROXY/NO_PROXYvariables keep working exactly as before. - Credentials in the proxy URL are supported, and the password is redacted from logs, delivery errors, the API, and the console — as the bot token already is.
- There is no way to disable TLS verification, by design.
- A proxy for the email and webhook channels is not implemented.
The channel configuration is used by both roles: the server decides which
delivery jobs to create when an event arrives, and the worker performs the
actual sending. Both processes must load the same channel configuration — if
the server has no channels, no delivery jobs are created and nothing is ever
sent (it will log a prominent warning at startup).
The PostgreSQL Compose profile does this correctly by mounting a single
alertloop.yaml into both the api and worker containers — one source of truth,
no drift. To use it:
cp alertloop.example.yaml alertloop.yaml # api_keys & channels optional
COMPOSE_PROFILES=postgres docker compose up -dThe single-process all mode has no such split and needs no special handling.
The OpenAPI contract lives at api/openapi.yaml and is served live at
/openapi.yaml, with Swagger UI at /swagger.
Documentation lives in two places today: this README (installation, deployment,
configuration, operations) and the interactive Swagger UI at /swagger on a
running instance (the full endpoint reference, request/response schemas, and
examples).
Download a prebuilt binary for your OS/arch from the repository's GitHub
Releases page — pick linux_amd64, linux_arm64, darwin_arm64,
windows_amd64, etc., unpack, and run. Each release also ships a
checksums_*.txt to verify the download.
You do not need a Windows machine to get a Windows binary (or a Mac for a Mac
binary). AlertLoop is CGO-free (pure-Go SQLite), so it cross-compiles — a
single build machine produces every OS/arch at once. Releases are built
automatically by CI when a version tag is pushed (.github/workflows/release.yml),
which builds the admin UI, cross-compiles all targets, and uploads them to the
GitHub Release.
To build the release artifacts yourself:
make admin # build the embedded admin UI (needs Node)
make release # cross-compile all targets into dist/
# equivalently: VERSION=v0.1.0 ./scripts/build-release.shFor a single local binary for your own machine, just make build (Go only).
- Always set
admin_token: if neitheradmin_tokennorapi_keysare set, the API accepts unauthenticated requests with full scope (a local-demo convenience; the process logs a warning at startup). Always setadmin_tokenon anything reachable by others. - TLS: AlertLoop serves plain HTTP and is designed to run behind an HTTPS reverse proxy (nginx, Caddy, Traefik). Terminate TLS there and forward to the container/port.
- External PostgreSQL: use
sslmode=require(or stricter) in the DSN. The bundled Compose Postgres usessslmode=disableonly because it is on a private Docker network. - Rate limiting is on by default (
rate_limitin the config). If your proxy already rate limits, you can disable it here. - SMTP: set
starttls: true(required upgrade, port 587) ortls: true(implicit TLS / SMTPS, port 465). AlertLoop will not send mail in plaintext when TLS is requested but unavailable. - Secrets: never commit real
alertloop.yaml/.env(they are gitignored); keep the*.exampletemplates only. - Monitor AlertLoop itself: alert on
deliveries.dead_letterfrom/v1/stats, and check/health/readyfrom outside this host — a service that is down cannot report that it is down. See OPERATIONS.md. - Back up before upgrading: migrations run automatically at startup and are not reversible in place. Downgrading is not supported; restoring a backup is.
Community Edition is licensed under AGPL-3.0-only (see LICENSE and
NOTICE). Planned Pro and Enterprise editions will be offered under a separate
commercial license.