๐ RabbitMQ + Celery + Docker โ Complete Real-World DevOps Debugging Guide (From Failure to Success)

๐ Executive Summary
In a real-world setup using RabbitMQ + Celery + Docker, I encountered multiple production-level issues:
SMTP credential mismatch (username & domain swapped)
Environment variable confusion (CI/CD vs local volume)
RabbitMQ connection failures
Container restart issues
Accidental
.envdeletion leading to application crash
These issues caused:
Email failures
Celery not connecting
Application downtime
Debugging confusion
This blog explains everything in detail โ what, why, how, where, when, who โ based on real debugging experience
๐ง What is RabbitMQ?
RabbitMQ is a message broker
๐ It acts like a middleman between services
๐ฏ Real-world example
User places order โ email should be sent
Without RabbitMQ:
User waits โ
With RabbitMQ:
Task stored โ processed later โ
๐ง Internal flow
Producer (Django) โ RabbitMQ โ Consumer (Celery worker)
๐ง What is Celery Worker?
Celery Worker is the executor
RabbitMQ โ sends task โ Celery executes
Example:
Send email โ executed by Celery worker
๐ง What is Celery Beat?
Celery Beat is a scheduler
Runs tasks at intervals
Example:
Every 1 minute โ check pending emails โ send
๐ฅ Complete Architecture
User โ Django โ RabbitMQ โ Celery Worker โ SMTP
โ
Celery Beat
๐จ CELERY_BROKER_URL (CRITICAL CONCEPT)
โ Wrong
CELERY_BROKER_URL=amqp://guest:guest@localhost:5672//
CELERY_BROKER_URL=amqp://guest:guest@127.0.0.1:5672//
๐ง Why wrong?
localhost = same container โ
๐ In Docker:
Each container = separate machine
So:
celery container โ localhost = celery itself
NOT RabbitMQ
โ Correct
CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672//
๐ง Why correct?
rabbitmq = docker-compose service name
๐ Docker provides internal DNS
๐ง Where is CELERY_BROKER_URL used in code?
settings.py
import os
CELERY_BROKER_URL = os.environ.get(
"CELERY_BROKER_URL",
"amqp://guest:guest@rabbitmq:5672//"
)
celery.py
from celery import Celery
app = Celery("project")
app.config_from_object("django.conf:settings", namespace="CELERY")
๐ Celery automatically reads:
CELERY_BROKER_URL โ connects to RabbitMQ
๐จ SMTP USERNAME & DOMAIN MISMATCH (REAL ISSUE)
โ Wrong .env
EMAIL_HOST=user@example.com
EMAIL_HOST_USER=smtp.example.com
๐ง Why wrong?
EMAIL_HOST = SMTP server
EMAIL_HOST_USER = login email
โ Result
SMTP silently rejects โ
Connection unexpectedly closed โ
โ
Correct .env
EMAIL_HOST=smtp.example.com
EMAIL_HOST_USER=user@example.com
EMAIL_HOST_PASSWORD=xxxx
EMAIL_PORT=587
EMAIL_USE_TLS=True
๐ง How to identify in future
๐ Logs
Connection unexpectedly closed
SendAsDenied
๐ Manual test
docker exec -it celery-worker python manage.py shell
from django.core.mail import send_mail
send_mail("test","hello","user@example.com",["test@example.com"])
๐ Network test
telnet smtp.example.com 587
๐จ ENVIRONMENT VARIABLE ISSUE (CRITICAL LESSON)
๐ด What I did initially Used GitLab CI variables Created .env in pipeline Used env_file
showing one stage in this docker compose file where using env_file to copy gitlab variable to .env inside working directory of the container but it didn't contain all the env variables like django SECRET_KEY but it was present in volume /home/app/.env in the server
rm -f .env
this is from gitlab-ci-yml
deploy_container:
image: docker:cli
stage: deploy
script:
- docker login -u "\(CI_REGISTRY_USER" -p "\)CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- rm -f .env #(here .env removed)
- echo "CELERY_BROKER_URL=$CELERY_BROKER_URL" > .env
- echo "EMAIL_HOST=$EMAIL_HOST" >> .env
- echo "EMAIL_PORT=$EMAIL_PORT" >> .env
- echo "EMAIL_USE_TLS=$EMAIL_USE_TLS" >> .env
- echo "EMAIL_HOST_USER=$EMAIL_HOST_USER" >> .env
- echo "EMAIL_HOST_PASSWORD=$EMAIL_HOST_PASSWORD" >> .env
- docker-compose down
- docker-compose pull
- docker-compose up -d
๐ฅ Result
SECRET_KEY missing โ (django)
App crashed โ
Developer panic โ
In Django, the SECRET_KEY is a critical cryptographic key used internally to secure and sign sensitive data such as user sessions, CSRF tokens, password reset links, and signed cookies; it acts like a private signature that ensures data cannot be tampered with or forged. If the SECRET_KEY is missing, Django cannot perform these security operations and the application will fail to start, which is why your app crashed when the .env file containing it was removed. If the SECRET_KEY is changed, all existing sessions and tokens become invalid because they were signed with the old key. For security reasons, it should never be exposed publicly or hardcoded in code, and must be stored securely in environment variables (like .env, CI/CD variables, or a secrets manager), especially in Docker setups where the application reads it during container startup.
๐ง Why?
Because app was using:
๐ It reads from local volume
The rm -f .env command in the CI/CD pipeline only removed the .env file from the current working directory (of the container ) of the pipeline (for example, ./ .env), where variables like CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672// were being injected, but this did not affect the actual environment file used by the container, which was mounted via Docker volume from /home/app/.env to /app/.env. Because of this, the application did not crash when rm -f .env was executed, since it was still reading all required variables (including SECRET_KEY) from the volume-based .env file; however, services like Celery and RabbitMQ could face issues if their required variables were missing from the pipeline-generated .env. The application itself crashed only after the volume mapping (/home/app/.env:/app/.env) was removed from docker-compose.yml and replaced with env_file, because at that point the container started relying entirely on the .env generated from CI/CD variables, and since those variables did not include the Django SECRET_KEY, the application started with incomplete configuration and failed to start. The application crash issue was resolved by reusing the volume mapping (/home/app/.env:/app/.env) and removing env_file from docker-compose.yml, allowing the container to again read the complete set of environment variables from the local .env file. After that, the remaining issue was resolved by adding CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672// directly into the local volume-based .env file at /home/app/.env, which is mounted inside the container at /app/.env, ensuring that Celery could correctly connect to RabbitMQ using the Docker service name.
โ Fix
Restored local .env
๐ฏ Why volume used?
Because:
All configs already present:
- DB
- SMTP
- SECRET_KEY
- APIs
๐จ CI/CD vs LOCAL ENV (MAJOR CONFUSION)
โ Problem
Pipeline .env โ Volume .env
๐ง Lesson
Always check where .env is coming from
๐จ SMTP CREDENTIAL DECISION
I received:
Production SMTP credentials
๐ง Decision
Did NOT use them โ
๐ Used working safe credentials โ
๐ฅ Best Practice
Never use production credentials blindly
๐จ RESTART ISSUE (VERY IMPORTANT)
โ What happened
Restarted only web container โ
๐ง Why it failed?
Celery still using old environment โ
๐ Each container has its own environment
โ Fix
docker-compose down
docker-compose up -d
๐ง Why it worked?
All containers restarted โ new env loaded everywhere
๐จ ALL PROBLEMS FACED (IN ORDER)
RabbitMQ connection failed
Wrong broker URL
.envconfusionDeleted
.envSMTP credentials expired
Username/domain mismatch
Restart mistake
SMTP permission issue
CI/CD vs local env confusion
๐ BEST PRACTICES
๐ฅ 1. Always know env source
CI vs volume vs env_file
๐ฅ 2. Never delete .env blindly
๐ฅ 3. Restart ALL containers after config change
๐ฅ 4. Use service names
rabbitmq โ
localhost โ
๐ฅ 5. Validate SMTP manually
๐ฅ 6. Use secrets manager (future improvement)
๐ฅ 7. Add health checks
๐ฏ FINAL LEARNING
DevOps is NOT setup
DevOps is debugging + understanding system behavior
๐ FINAL RESULT
Email sent successfully โ
Celery working โ
RabbitMQ working โ
System stable โ
๐ Task Flow Diagram
๐ Final Architecture
User โ Django โ RabbitMQ โ Celery โ SMTP
โ
Celery Beat
๐ฅ Conclusion
This journey taught me:
RabbitMQ โ message broker
Celery โ executor
Celery Beat โ scheduler
Docker โ networking isolation
Env management โ critical
SMTP โ tricky debugging
Restart โ crucial
Here is the current working ci pipeline and Docker compose file
this is gitlab-ci.yml
variables:
IMAGE_NAME: $CI_REGISTRY_IMAGE:latest
VERSION_TAG: \(CI_COMMIT_REF_NAME-\)CI_COMMIT_SHORT_SHA
services:
- docker:dind
stages:
- build
- deploy
# -------------------------------
# BUILD STAGE
# -------------------------------
build_image:
image: docker:cli
stage: build
script:
- docker login -u "\(CI_REGISTRY_USER" -p "\)CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t \(IMAGE_NAME -t \)CI_REGISTRY_IMAGE:$VERSION_TAG .
- docker push $IMAGE_NAME
- docker push \(CI_REGISTRY_IMAGE:\)VERSION_TAG
only:
- main
tags:
- docker-runner
# -------------------------------
# DEPLOY STAGE
# -------------------------------
deploy_container:
image: docker:cli
stage: deploy
script:
- docker login -u "\(CI_REGISTRY_USER" -p "\)CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
# NOTE:
# Using .env from server volume instead of CI variables
# Avoid mixing multiple env sources
- docker-compose down
- docker-compose pull
- docker-compose up -d
- sleep 15
- echo "===== RUNNING CONTAINERS ====="
- docker ps -a
- echo "===== DOCKER COMPOSE STATUS ====="
- docker-compose ps
- echo "===== APPLICATION LOGS ====="
- docker logs app-container || true
- echo "===== CONTAINER STATE ====="
- docker inspect app-container --format='{{.State.Status}}'
- docker image prune -f
only:
- main
tags:
- docker-runner
In this pipeline, we are NOT creating a .env file inside CI/CD.
Instead, the application uses a .env file mounted from the server using Docker volumes.
This avoids conflicts between CI/CD variables and runtime environment configuration.
Docker Compose file
version: '3.8'
services:
web:
image: '\({CI_REGISTRY_IMAGE}:\){VERSION_TAG}'
container_name: app-web
restart: always
ports:
- "8000:8000"
volumes:
# Mount environment file
- /opt/app/.env:/app/.env
# Application data (generic paths)
- /opt/app/data:/app/data
- /opt/app/logs:/app/logs
# Optional override file (for configs/scripts)
- /opt/app/config_override.py:/app/config_override.py
depends_on:
- rabbitmq
rabbitmq:
image: rabbitmq:3-management
container_name: rabbitmq
restart: always
ports:
- "5672:5672"
- "15672:15672"
celery:
image: '\({CI_REGISTRY_IMAGE}:\){VERSION_TAG}'
container_name: celery-worker
command: celery -A app worker --loglevel=info
volumes:
- /opt/app/.env:/app/.env
- /opt/app/config_override.py:/app/config_override.py
depends_on:
- rabbitmq
restart: always
celery-beat:
image: '\({CI_REGISTRY_IMAGE}:\){VERSION_TAG}'
container_name: celery-beat
command: celery -A app beat --loglevel=info
volumes:
- /opt/app/.env:/app/.env
- /opt/app/config_override.py:/app/config_override.py
depends_on:
- rabbitmq
restart: always
In this setup, the .env file is mounted from the host machine using Docker volumes.
This means the application reads environment variables directly from the server instead of relying on CI/CD variables.
This approach is useful when:
All configurations already exist on the server
You want quick recovery without rebuilding pipelines
You want to avoid conflicts between multiple env sources
However, in production, it is recommended to move toward CI/CD variables or a secrets manager for better security and maintainability.




