Skip to content

Part 5: Background Workers

Add a Celery worker to process tasks asynchronously using Redis as a broker.

What you'll learn

  • Adding Celery to a Django project
  • Defining a portless worker service in appmecca.yaml
  • Enabling Redis with cache: enabled: true
  • Managing worker services (start, stop, restart, logs)
  • The command override for non-HTTP services

What you'll need


Step 1: Add Celery to the backend

Add to backend/requirements.txt:

celery==5.4
redis==5.2

Create backend/config/celery.py:

import os
from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")

app = Celery("todo")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

Edit backend/config/__init__.py:

from .celery import app as celery_app

__all__ = ("celery_app",)

Add Celery settings to backend/config/settings.py:

import os

CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"

Step 2: Create an async task

Create backend/core/tasks.py:

import logging
from celery import shared_task

logger = logging.getLogger(__name__)


@shared_task
def send_todo_notification(todo_id, action):
    """
    Simulate sending a notification when a todo is created or completed.
    Replace this with real email/webhook logic in production.
    """
    logger.info(f"Notification: todo {todo_id} was {action}")
    # In a real app:
    # send_email(user.email, f"Todo {action}", ...)
    return {"todo_id": todo_id, "action": action, "notified": True}

Trigger the task from the viewset. Update backend/core/views.py:

from django.http import JsonResponse
from rest_framework import viewsets
from .models import Todo
from .serializers import TodoSerializer
from .tasks import send_todo_notification


def health(request):
    return JsonResponse({"status": "healthy"})


class TodoViewSet(viewsets.ModelViewSet):
    queryset = Todo.objects.all()
    serializer_class = TodoSerializer

    def perform_create(self, serializer):
        todo = serializer.save()
        send_todo_notification.delay(todo.id, "created")

    def perform_update(self, serializer):
        todo = serializer.save()
        if todo.done:
            send_todo_notification.delay(todo.id, "completed")

Step 3: Update appmecca.yaml

Add the worker service and enable Redis:

database:
  enabled: true

redis:
  enabled: true

containers:
  - name: backend
    dockerfile: backend/Dockerfile
    context: backend/

  - name: frontend
    dockerfile: frontend/Dockerfile
    context: frontend/

deploy:
  - name: backend
    container: backend
    port: 8000
    path: /api
    health_check:
      path: /health
    environment:
      CELERY_BROKER_URL: "${redis_url}"

  - name: frontend
    container: frontend
    type: spa
    command: 'nginx -g "daemon off;"'   # serves the built bundle from a container
    spa_root: /usr/share/nginx/html
    port: 3000
    health_check:
      path: /
    environment:
      PUBLIC_API_URL: "${service:backend}"
      PUBLIC_APP_TITLE: "Todo App"

  - name: worker
    container: backend
    command: "celery -A config worker --loglevel=info"
    environment:
      CELERY_BROKER_URL: "${redis_url}"

Portless services

The worker service has no port field. AppMecca treats it as a background process — it gets no public hostname, no route on the platform HTTPS router, and no health check. The container runs the command instead of the Dockerfile's default CMD.

Top-level cache: is the managed Redis

The cache: enabled: true block at the top level of appmecca.yaml turns on the managed Redis cache and injects REDIS_URL into every container. It defaults to off.

Do not confuse it with the per-service deploy[].cache: block, which configures edge caching for a web service and is a completely different feature. They share a name and nothing else.

Shared container image

The worker uses the same backend container image but with a different entrypoint command. This avoids building a separate image for the worker.

Step 4: Build and deploy

mecca build trigger -e dev -g git@github.com:yourorg/todo-app.git
mecca app deploy todo-app -e dev

mecca app show todo-app now lists three services: backend, frontend, and worker. Only backend and frontend have URLs.

Step 5: Manage the worker service

Read worker logs to see task execution. Select the service with --service:

mecca logs todo-app --type container --service worker -e dev

Create a todo via the frontend or curl, then check the worker logs for the notification task output.

Restart the worker after a code change. Here the service name is a positional argument, following the app name:

mecca app restart todo-app worker --force -e dev

Stop and start the worker independently:

mecca app stop todo-app worker --force -e dev
mecca app start todo-app worker -e dev

Worker restarts

Restarting a worker sends SIGTERM to the Celery process. Tasks that are currently executing will attempt to finish within the shutdown timeout. Design tasks to be idempotent in case they are interrupted.

Step 6: Verify the full flow

  1. Open the frontend and create a new todo
  2. Check worker logs for the notification:
mecca logs todo-app --type container --service worker --since 5m -e dev

You should see output like:

Notification: todo 1 was created
Task send_todo_notification succeeded in 0.002s
  1. Mark the todo as done in the frontend
  2. Check logs again for the "completed" notification

What's next

You have a complete three-service application. In Part 6: Branch Deployments, you will deploy a feature branch alongside your main deployment for testing.