Skip to content

Part 3: Going Full-Stack

Add a React frontend to your Django backend and deploy both containers from a single repository.

What you'll learn

  • Structuring a monorepo with separate backend and frontend directories
  • Building multiple containers from one repository using appmecca.yaml
  • Using ${service:backend} for inter-service URL resolution
  • Configuring nginx to serve a React SPA
  • Viewing per-service logs

What you'll need


Step 1: Reorganize the repository

Move the Django code into a backend/ directory and create a frontend/ directory:

todo-app/
├── backend/
│   ├── config/
│   ├── core/
│   ├── entrypoint.sh
│   ├── manage.py
│   ├── requirements.txt
│   └── Dockerfile
├── frontend/
│   ├── src/
│   ├── public/
│   ├── package.json
│   ├── vite.config.js
│   ├── nginx.conf
│   └── Dockerfile
└── appmecca.yaml

Keep appmecca.yaml at the root

The appmecca.yaml file must remain at the repository root. AppMecca reads it before building any containers.

Step 2: Scaffold the React frontend

cd frontend
npm create vite@latest . -- --template react
npm install

Replace src/App.jsx with a todo list that calls the backend API:

import { useState, useEffect } from "react";

const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";

export default function App() {
  const [todos, setTodos] = useState([]);
  const [title, setTitle] = useState("");

  useEffect(() => {
    fetch(`${API_URL}/api/todos/`).then((r) => r.json()).then(setTodos);
  }, []);

  async function addTodo(e) {
    e.preventDefault();
    const res = await fetch(`${API_URL}/api/todos/`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title }),
    });
    setTodos([await res.json(), ...todos]);
    setTitle("");
  }

  async function toggleTodo(todo) {
    const res = await fetch(`${API_URL}/api/todos/${todo.id}/`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ...todo, done: !todo.done }),
    });
    const updated = await res.json();
    setTodos(todos.map((t) => (t.id === updated.id ? updated : t)));
  }

  async function deleteTodo(id) {
    await fetch(`${API_URL}/api/todos/${id}/`, { method: "DELETE" });
    setTodos(todos.filter((t) => t.id !== id));
  }

  return (
    <div style={{ maxWidth: 600, margin: "2rem auto", fontFamily: "sans-serif" }}>
      <h1>Todo App</h1>
      <form onSubmit={addTodo}>
        <input value={title} onChange={(e) => setTitle(e.target.value)}
          placeholder="What needs to be done?" required />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <span onClick={() => toggleTodo(todo)}
              style={{ textDecoration: todo.done ? "line-through" : "none", cursor: "pointer" }}>
              {todo.title}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Step 3: Create the frontend Dockerfile

Create frontend/nginx.conf:

server {
    listen 3000;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Create frontend/Dockerfile:

# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build

# Production stage
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

Step 4: Update the backend

Add CORS support. Add django-cors-headers==4.4 to backend/requirements.txt, then in config/settings.py add "corsheaders" to INSTALLED_APPS, add "corsheaders.middleware.CorsMiddleware" as the first middleware, and set CORS_ALLOW_ALL_ORIGINS = True.

Step 5: Update appmecca.yaml

database:
  enabled: true

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

  - name: frontend
    dockerfile: frontend/Dockerfile
    context: frontend/
    args:
      VITE_API_URL: "${service:backend}"

deploy:
  - name: backend
    container: backend
    port: 8000
    health_check:
      path: /health

  - name: frontend
    container: frontend
    port: 3000
    health_check:
      path: /

Inter-service URL resolution

${service:backend} resolves at build time to the public HTTPS URL of the backend service, for example https://todo-app-backend-yourgroup.dev.yourdomain.com. AppMecca passes it as a Docker build argument.

A typo in the service name is caught at registration time — the validator rejects ${service:...} references to services that are not declared in the same deploy: block.

Step 6: Build and deploy

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

Both containers are built and deployed. Every service gets its own hostname, always including the service name:

  • Frontend: https://todo-app-frontend-yourgroup.dev.yourdomain.com
  • Backend: https://todo-app-backend-yourgroup.dev.yourdomain.com

Step 7: View per-service logs

Check the status of individual services:

mecca app show todo-app -e dev

Read the frontend container's stdout. The service is selected with --service, not as a positional argument:

mecca logs todo-app --type container --service frontend -e dev
mecca logs todo-app --type container --service frontend --since 1h -e dev

And the backend:

mecca logs todo-app --type container --service backend -e dev
mecca logs todo-app --level error -e dev        # errors from every source

Debugging build failures

If the frontend build fails, check the build logs. A common issue is VITE_API_URL not being available at build time. Verify your appmecca.yaml has the args section under the frontend container.


What's next

The frontend currently bakes the API URL at build time. In Part 4: SPA Runtime Config, you will switch to runtime configuration so the same image works across environments.