Vigil is a portfolio project that demonstrates a production-grade uptime monitoring system built end-to-end: a Django REST API, a Celery-powered background task queue, and an Angular 21 dashboard.
Users register, add HTTP endpoints, and the system checks them every 5 minutes. On the first failed check it opens an incident and sends an email alert. When the endpoint recovers, it closes the incident and sends a resolution email. Failed alert emails are automatically retried with exponential backoff via a dead-letter queue.
The goal is to show how the layers fit together — API design, async tasks, stateful incident tracking, email reliability, and a reactive frontend — rather than to cover maximum surface area.
Most portfolio projects demonstrate CRUD. Vigil demonstrates the patterns that appear in production systems:
- Async task queues — work is offloaded to Celery workers; the API never blocks on outbound HTTP.
- Idempotency — the check task guards against double-execution if Beat fires while a previous check is still running.
- Incident lifecycle — the system tracks state transitions (up → down → up), not just individual check results.
- Email reliability — failed sends are persisted to a dead-letter table and retried with exponential backoff rather than dropped silently.
- SSRF defence — two validation layers prevent users from pointing the monitor at internal infrastructure.
- Security basics — token expiry, global rate limiting, CORS restriction in production.
- DRF with
ModelViewSetfor endpoints, check results, and incidents - Custom
ExpiringTokenAuthentication— tokens expire after 24 hours (configurable viaAUTH_TOKEN_EXPIRY_HOURS) - Global rate limiting:
AnonRateThrottle(100 req/h) andUserRateThrottle(2 000 req/h), backed by Redis cache in production - SSRF validation at serializer level: rejects private IP ranges, loopback, link-local (including
169.254.169.254), and blocked hostnames before the record is saved - Auto-generated OpenAPI schema via
drf-spectacular; interactive Swagger UI at/api/docs/ - Custom
@actionendpoints:POST /endpoints/{id}/check_now/andPOST /incidents/{id}/resend_alert/
- Standalone components throughout;
inject()-based DI, no NgModules - Signal-based dashboard state (
signal,computed) with a single derivedrowssignal that pre-computes status per endpoint — no repeated method calls in the template - Angular Material table, summary cards, Chart.js response-time line chart
AuthGuard(redirects unauthenticated users to/login) andGuestGuard(redirects logged-in users away from/loginand/register)MatSnackBarfor non-blocking error feedback; backend validation errors surfaced to the user- Endpoints without a first check result show Pending rather than a misleading Down status
schedule_checkstask (every 5 minutes, via Beat) fans out onecheck_endpoint.delay(id)per active endpointcheck_endpointis idempotent: skips re-checking if the elapsed time since last check is less than the configured interval- HTTP checks use
safe_get_request(), which re-validates DNS on every request and follows redirects only after re-checking each hop — defending against DNS rebinding attacks - Status < 500 counts as "up" (4xx responses are treated as the endpoint being reachable)
Incidentis created on the first DOWN result after a period of UP resultsIncidentis closed (andended_atstamped) on the first UP result after a DOWNduration_seconds()computed fromstarted_at/ended_at- Alert emails reference the incident and include the duration on resolution
send_alert_emailwrites aFailedEmailrecord on any send failureretry_failed_emails()Celery task runs every 2 minutes, queries pending records, and re-attempts delivery- Exponential backoff: first retry at 5 minutes, doubling each attempt, capped at 720 minutes (12 hours)
- Configurable via
FAILED_EMAIL_MAX_RETRIES(default 8) andFAILED_EMAIL_BASE_DELAY_MINUTES(default 5) - Each
FailedEmailhas astatusproperty (pending/sent/exhausted) visible in the Django admin
Two independent validation layers:
- Serializer-level (
validate_url_for_ssrf) — runs at save time; catches obviously bad URLs before they reach the database - Task-level (
safe_get_request) — runs on every HTTP request; re-resolves DNS to catch DNS rebinding, manually follows each redirect and re-validates the next hop
Blocked ranges include RFC 1918 private addresses, loopback (127.0.0.0/8), link-local (169.254.0.0/16), CGNAT (100.64.0.0/10), and their IPv6 equivalents.
| Suite | Runner | Tests |
|---|---|---|
| Backend (tasks, DLQ, SSRF, views, auth) | Django test runner | 143 |
| Frontend (services, guards, dashboard) | Vitest + Angular TestBed | 29 |
- Python 3.12+
- Node.js 20+
- Redis — either installed locally or via Docker:
docker run -d -p 6379:6379 redis:7
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements/dev.txt
python manage.py migrate
python manage.py createsuperuser # optional — needed for /admin_portal/
python manage.py runserver # API at http://localhost:8000In separate terminals, start the Celery worker and scheduler:
celery -A config worker -l info
celery -A config beat -l infocd frontend
npm install
ng serve # dev server at http://localhost:4200The dev proxy (proxy.conf.json) rewrites /api/* to http://localhost:8000, so no CORS
configuration is needed during local development.
# Backend
cd backend && source venv/bin/activate
python manage.py test apps
# Frontend
cd frontend
ng test --watch=false- Open
http://localhost:4200— the route guard redirects to/login. - Click Register and create an account. You are logged in automatically.
- The dashboard shows an empty state. Click Add your first endpoint.
- Enter a name and a public URL (e.g.
https://httpstat.us/200). Save. - The endpoint appears with status Pending — no check has run yet.
- Click the refresh icon (Trigger check) to queue an immediate check via
POST /api/v1/endpoints/{id}/check_now/. - Refresh the page. Status updates to Up (green icon + text) or Down (red icon + text).
- Visit
http://localhost:8000/api/docs/to explore the full API in Swagger UI. - If you ran
createsuperuser, visithttp://localhost:8000/admin_portal/to inspect incidents, check results, and theFailedEmaildead-letter queue.
The included docker-compose.yml runs the full stack locally with a single command:
cp .env.example .env
docker compose up --buildThe example file contains working demo values — no editing required to run locally.
Services:
| Service | Role |
|---|---|
db |
PostgreSQL 15 |
redis |
Celery broker |
backend |
Gunicorn + Django |
worker |
Celery worker |
beat |
Celery Beat scheduler |
frontend |
nginx — serves Angular build, proxies /api to Django |
The app is available at http://localhost:80.
docker-compose.yml uses config.settings.docker: PostgreSQL, console email (emails appear in
docker logs vigil-backend-1), and DEBUG=True. No SMTP credentials are needed.
A separate docker-compose.prod.yml uses config.settings.prod: real SMTP, Redis cache backend,
tighter throttle limits, and CompressedManifestStaticFilesStorage. Fill in the email and
CORS_ALLOWED_ORIGINS fields in .env before using it.
backend/
├── apps/
│ ├── accounts/ # custom User model, registration, ExpiringTokenAuthentication
│ └── monitors/ # Endpoint, CheckResult, Incident, FailedEmail models + Celery tasks
├── config/ # Django settings split: base / dev / docker / prod
├── requirements/ # base.txt (runtime), dev.txt, prod.txt
└── manage.py
frontend/
├── src/app/
│ ├── core/ # AuthService, EndpointService, CheckService, interceptor, guards
│ └── features/ # dashboard, auth (login, register)
└── proxy.conf.json # rewrites /api/* → localhost:8000 in development
Features intentionally left out of scope for this portfolio project:
- Incident history page in the Angular dashboard
- Per-user alert cooldown and check interval configuration via the UI (backend already supports the data model)
- Webhook notifications (Slack, Teams, Discord)
- Email digest (daily / weekly summary instead of per-incident alerts)
- Multi-region probe distribution
MIT © Clifford


