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¶
- A working Django + PostgreSQL app from Part 2: Adding a Database
- Node.js 20+ installed locally
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¶
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¶
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:
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.