Architecture
How Fabrik is put together — the services, data stores, queues, and WebSocket channels behind the visual query builder.
Fabrik runs as a stack of eleven containers: one Django ASGI web process, a Celery worker and scheduler, three event consumers that listen to AWX, a React frontend served by Nginx, and four backing services (PostgreSQL, Neo4j, Redis, RabbitMQ). Nothing in the stack is a single point of unique magic — it's a conventional Django + Celery + Channels application with one twist: Neo4j stores the ACI Managed Information Model so the query builder can reason about class relationships at draw time.
The web tier is stateless. Every long-running thing — query execution, AWX job dispatch, notification delivery, MIM import — is a Celery task. Real-time updates travel over WebSockets through Django Channels, with Redis as the channel layer. AWX status events come in through a dedicated RabbitMQ topic exchange consumed by three small workers.
High-level diagram
The stack at a glance
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite, React Flow, Zustand, TanStack Query, Tailwind (served by Nginx in production) |
| Backend | Django 5 + Django REST Framework + Channels, served by Daphne (ASGI) |
| Workers | Celery (worker + beat) |
| Event bus | RabbitMQ 4.1 with a dedicated topic exchange for AWX events |
| Relational store | PostgreSQL 17 |
| Graph store | Neo4j 5.26 (ACI MIM metadata only) |
| Cache & channel layer | Redis 8 |
| Auth | JWT for API clients; optional LDAP / Active Directory |
| Secrets at rest | Fernet symmetric encryption for APIC passwords, AWX OAuth tokens, and other credentials |
Services
Eleven containers make up a default deployment:
| Container | Purpose |
|---|---|
postgres | Primary relational store — users, saved queries, scheduled tasks, AWX metadata, Time Machine snapshots, audit logs. |
redis | Celery result backend, Django Channels channel layer, and general application cache. |
rabbitmq | Celery broker and the awx.events topic exchange for AWX status events. |
neo4j | Stores the ACI Managed Information Model — class hierarchy, relationships, and property metadata. |
backend | The ASGI web process. Entrypoint runs database migrations, bootstraps the MIM cache, then starts Daphne on port 8000. Serves both REST and WebSocket traffic. |
celery-worker | Executes background tasks across seven queues (see below). |
celery-beat | Periodic scheduler — fires the seven recurring tasks listed in the next section. |
event-consumer-job | Subscribes to AWX job status events (job.status.* on awx.events) and updates execution records in real time. |
event-consumer-workflow | Subscribes to AWX workflow status events (workflow.status.*). |
event-consumer-output | Subscribes to AWX job output events (job.output.*) and streams logs to the live terminal. |
frontend | In production, Nginx serves the compiled Vite bundle and proxies /api and /ws to the backend. |
The event consumers are separate processes on purpose. Each runs a blocking RabbitMQ consumer loop; isolating them keeps a slow output stream from blocking job-status updates, and lets you scale or restart them independently.
Data stores
PostgreSQL is the system of record. Every durable object — user accounts, saved queries, scheduled tasks, AWX requests and executions, Time Machine snapshots, audit events — lives here.
Neo4j stores only the ACI MIM metadata. Nodes are Class and Property; edges describe containment, relative-name mappings, and property ownership. The query builder walks this graph to validate connections on the canvas and suggest what classes a given parent can contain.
Neo4j does not mirror your live fabric. It never holds tenant, EPG, or endpoint data. Every live query goes straight to the APIC over REST. Neo4j exists so the canvas knows that fvBD can contain fvSubnet — not what subnets exist on your BDs right now.
Redis plays three roles: it's the Celery result backend, the Django Channels channel layer (so a Celery worker can push a WebSocket message to a browser), and a general cache — including the TTL'd MIM query cache.
RabbitMQ is the Celery broker and hosts the awx.events topic exchange. AWX publishes status and output events there (via webhook or a bridge, depending on how you wire AWX); the three event consumers bind to routing keys job.status.*, workflow.status.*, and job.output.*.
Celery queues
The worker listens on seven queues. Each has a narrow purpose so a long AWX run can't starve user-triggered query execution, and vice versa.
| Queue | What runs here |
|---|---|
query_exec | On-demand APIC query executions triggered by the user |
scheduled | Scheduled task runner and the tasks it dispatches |
awx_monitor | Lightweight AWX job polling (awx.sync_running_jobs) |
awx_exec | AWX automation request execution — can be heavy |
mim_import | MIM registry imports (mim_registry.import_mim_version) |
maintenance | Daily and periodic housekeeping — notification cleanup, stale-execution sweep, password-reset code expiry |
celery | Default queue, used as a fallback |
Celery beat schedule
Celery beat fires seven recurring tasks:
| Task | Schedule |
|---|---|
queries.check_scheduled_tasks | Every 1 minute |
awx.sync_running_jobs | Every 30 seconds |
awx.cleanup_stale_executions | Every 30 minutes |
notifications.flush_notification_digests | Every 60 seconds |
notifications.check_escalations | Every 5 minutes |
notifications.cleanup_old_notifications | Daily at 04:00 |
users.cleanup_expired_reset_codes | Daily at 05:00 |
All schedules are expressed in the server's timezone, controlled by the TZ environment variable.
The event-driven AWX pipeline
Most Ansible Tower integrations poll AWX for job status. Fabrik does too — the awx.sync_running_jobs beat task is a safety net — but the primary path is event-driven.
AWX is configured to publish job events as webhooks. A lightweight bridge forwards those webhooks into the awx.events topic exchange on RabbitMQ, using routing keys that match the event type:
job.status.*— a job started, succeeded, failed, or was cancelledworkflow.status.*— a workflow job changed statejob.output.*— a new chunk of stdout is available
Each routing key is consumed by its own dedicated container (event-consumer-job, event-consumer-workflow, event-consumer-output). The consumer updates the relevant AutomationExecution record, then broadcasts a WebSocket event through the Channels layer so any browser watching that execution sees the change immediately.
If AWX isn't in use, these consumers simply sit idle — no events arrive, no work is done.
WebSocket channels
Daphne terminates WebSocket connections at five paths:
| Path | Purpose |
|---|---|
ws/chain-execution/<job_id>/ | Progress and results for a running query chain |
ws/notifications/ | Per-user real-time notifications |
ws/awx/request/<request_id>/ | Aggregate status for a multi-execution AWX request |
ws/awx/execution/<execution_id>/ | A single AWX execution: status, output stream, final result |
ws/mim-import/<task_id>/ | Progress while importing a new MIM version from the registry |
Authentication is checked by middleware before any consumer code runs; the frontend passes a short-lived JWT ticket at connection time.
External systems
Fabrik relies on a small number of systems that live outside the stack. None of them is a container Fabrik ships.
- Cisco APIC — the required one. Every live query goes here. An administrator registers each APIC as a connection; credentials are stored Fernet-encrypted.
- AWX / Ansible Tower — optional. Needed only if you use the Ansible module. Fabrik authenticates with an OAuth token and receives status events through the RabbitMQ pipeline described above.
- SMTP server — optional. Used for notification emails. Bring your own relay; Fabrik does not run one.
- LDAP / Active Directory — optional. The backend image includes the LDAP libraries so you can point Fabrik at a corporate directory for sign-in.
AWX projects may themselves be backed by a Git repository — GitLab, Gitea, GitHub Enterprise, or any other — so AWX can pull playbooks from version control. That's configured entirely on the AWX side. Fabrik has no SCM integration of its own and does not read or write to any Git service. If you don't use Ansible, you don't need an SCM at all.
Request lifecycle
It helps to trace one request end-to-end. Suppose you draw a query on the canvas and click Execute:
- The frontend POSTs the query graph to the backend REST API.
- The backend validates it, creates a
ChainExecutionrecord, and enqueues a task on thequery_execCelery queue via RabbitMQ. - A Celery worker picks up the task, resolves the APIC connection, and calls the APIC REST API — one request per node in the chain, possibly in parallel.
- As each node completes, the worker pushes a progress event through the Channels layer (backed by Redis) to the group
chain-execution-<job_id>. - Your browser, already connected to
ws/chain-execution/<job_id>/, receives each event and updates the canvas in real time. - When the final node finishes, the worker persists the result to Postgres, emits one last WebSocket event, and the task ends.
The AWX path looks the same, with two differences: the task runs on the awx_exec queue and calls AWX instead of the APIC, and the status updates arrive through the event-consumer pipeline instead of being emitted by the worker itself.
Deployment topology
A default install runs all eleven containers on a single Docker host. The frontend is the only container you typically expose publicly; everything else binds to the internal Docker network. A reverse proxy (Nginx, Caddy, HAProxy — your choice) sits in front to terminate TLS, route / to the frontend, and forward /api and /ws to the backend.
Horizontal scaling is possible — multiple Celery workers, multiple backends behind a load balancer — but the defaults are sized for a single-host deployment. For install, environment variables, TLS, backups, and upgrades, see the Deployment guide.