All Articles
Monitoring & Observability

Flower + Celery on Django: How I Found Silently Failing Tasks on Tabeer.ai

Shahid MalikBy Shahid MalikSeptember 4, 20267 min read

Users weren't getting some transactional emails. The mail-sending code looked fine, the logs weren't obviously screaming — the actual answer was Celery tasks failing with zero visibility into it. Here's the practical setup: systemd unit, basic auth, nginx subdomain, real commands.

The Problem That Started This

On Tabeer.ai, some transactional emails — verification links, password resets — just weren't showing up for a subset of users. Not all of them, not consistently, and nothing was throwing an obvious error anyone was watching. Digging in, the root cause was Celery tasks failing outright, with no monitoring layer surfacing that a task had failed at all — the only trace was in worker logs you'd have to already know to go grep. A user not getting an email and a developer having no idea a task crashed are the same failure from two different vantage points, and closing that gap is the entire point of what I set up.

Worth being precise about what this fixes and what it doesn't: Flower gives you visibility into a task failing, retrying, or timing out. It does not fix a task that succeeds from Celery's point of view but whose actual effect fails downstream — a send_mail() call that returns cleanly but gets rejected later by the receiving mail server, for instance, is invisible to Flower too, because as far as Celery is concerned, that task worked. Flower closes the "silent crash" gap, not the "downstream success/failure of what the task did" gap. Both matter; they're different problems with different fixes.

Install

pip install flower

Standard, no surprises. One version pin worth being explicit about in requirements.txt if you're touching a real production deploy pipeline, same as any other dependency:

flower>=2.1.0

Running It as a systemd Service — the Gotcha

Flower needs to run continuously, same as a Celery worker. The natural move is a systemd unit matching the existing worker/beat services:

[Unit]
Description=Tabeer.ai Celery Flower (monitoring)
After=network.target redis-server.service

[Service]
User=ubuntu
Group=ubuntu
WorkingDirectory=/opt/tabeerdotai/backend
ExecStart=/opt/tabeerdotai/backend/.venv/bin/celery -A config flower --address=127.0.0.1 --port=5555 --basic_auth=admin:somepassword
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

That works — but it puts the password in plaintext in a file at /etc/systemd/system/, readable by anyone with sudo on the box. Moving it to an env file is one line of config away in theory:

EnvironmentFile=/opt/tabeerdotai/backend/flower.env
ExecStart=/opt/tabeerdotai/backend/.venv/bin/celery -A config flower --basic_auth=${FLOWER_BASIC_AUTH}

This does not work as written, and the failure mode is quiet: Celery starts fine, but the actual basic-auth credential Flower receives is the literal eight-character string ${FLOWER_BASIC_AUTH} — because systemd's ExecStart execs the binary directly. It never goes through a shell, so it never does variable expansion. EnvironmentFile correctly populates the process environment; ExecStart just never reads from it inline like that.

The actual fix is routing the whole command through a shell explicitly:

EnvironmentFile=/opt/tabeerdotai/backend/flower.env
ExecStart=/bin/sh -c '/opt/tabeerdotai/backend/.venv/bin/celery -A config flower --address=127.0.0.1 --port=5555 --basic_auth=${FLOWER_BASIC_AUTH} --max_tasks=2000'

/bin/sh -c '...' gives you a shell that does expand ${FLOWER_BASIC_AUTH} before Celery ever sees the argument — and the credential itself lives only in flower.env (chmod 600, outside version control), not in a world-readable unit file.

# flower.env
FLOWER_BASIC_AUTH=admin:<strong generated password>

Bind to 127.0.0.1 only — Flower should never be reachable directly from the internet on its own port; it goes through a reverse proxy, next.

Exposing It: a Subdomain, Not a Path

The obvious move is tabeer.ai/flower/. Skip it — Flower's --url_prefix option for subpath-serving has real, documented friction with its static assets and websocket connection unless configured exactly right. A dedicated subdomain sidesteps the whole problem and, if you already have a wildcard TLS cert (*.yourdomain.com), costs nothing extra to set up:

server {
    listen 80;
    server_name flower.tabeer.ai;
    return 301 https://flower.tabeer.ai$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name flower.tabeer.ai;
    ssl_certificate     /etc/ssl/certs/tabeer_origin.pem;
    ssl_certificate_key /etc/ssl/private/tabeer_origin.key;

    location / {
        proxy_pass http://127.0.0.1:5555;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Then one DNS record — flower.tabeer.ai → the server's IP, proxied through Cloudflare for the same DDoS/edge protection the main domain gets — and it's live.

Verifying Auth Actually Works (Don't Skip This)

Basic auth misconfigured silently is worse than no auth at all, because it looks protected. Three checks, every time:

curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5555/                       # expect 401
curl -o /dev/null -w '%{http_code}\n' http://admin:wrong@127.0.0.1:5555/           # expect 401
curl -o /dev/null -w '%{http_code}\n' http://admin:<real>@127.0.0.1:5555/          # expect 200

All three, not just the last one — confirming no creds and wrong creds both fail is what actually proves the auth is enforced, not just present in the config.

The Resource Cost, Measured

This runs on a small, memory-constrained EC2 instance — the same one where I'd already skipped running Grafana Alloy for exactly this reason. Flower is much lighter, but not free: available memory dropped from roughly 208MB to 188MB, swap usage rose from ~151MB to ~352MB after starting it. Real overhead, worth knowing before assuming "just a monitoring tool" is resource-neutral — it isn't, though on this box it settled and stayed stable rather than climbing.

What It's Actually For

Task-level visibility that didn't exist before: which tasks failed, their arguments, their traceback, whether they retried and how many times, per-worker throughput. The next time an email or a background job goes missing, the question changes from "did something break, and where" to "open Flower, look at the failed task, read the traceback" — turning a user-facing mystery into a five-minute diagnosis, which is the entire point of adding observability tooling in the first place.

If you're running Celery in production without task-level monitoring, or want a second look at how your background jobs are actually behaving, get in touch.

Related Articles

Shahid Malik - AI-First Odoo Consultant

Shahid Malik

AI-First Odoo ERP Specialist

Shahid Malik is an AI-first Odoo consultant helping businesses solve complex ERP and business process challenges. His work combines Odoo consulting, process optimization, automation, integrations, migrations, and practical AI solutions to build scalable and reliable business systems.

Book a consultation for your Odoo project
Discuss Your Odoo Project