Project: deploy a full-stack app with CI/CD
Deploy the Brew & Bean full-stack app end to end: tests, Docker images, GitHub Container Registry, a GitHub Actions pipeline that deploys over SSH to your own server, HTTPS with Caddy, health checks and rollback.
Goal. Take the Brew & Bean ordering app (a FastAPI backend and a React frontend) from your laptop to a public URL, with a pipeline that does the boring parts for you. When you finish, every push to main will: run the backend tests, build the frontend, build two Docker images, push them to GitHub Container Registry (GHCR), connect to your server over SSH, start the new version, and check that it is healthy.
The finished result. A repository with this shape:
brew-bean/
backend/
main.py
test_main.py
requirements.txt
requirements-dev.txt
Dockerfile
frontend/
package.json
vite.config.js
index.html
src/main.jsx
src/App.jsx
nginx.conf
Dockerfile
compose.yaml (local run)
compose.prod.yaml (server run)
Caddyfile (HTTPS and routing on the server)
.github/workflows/ci-cd.ymlPrerequisites. You should have done *Docker: images and containers*, *Docker Compose for multi-service apps*, *CI/CD with GitHub Actions* and *Git and GitHub: the team workflow*, plus *Linux command line essentials* for the server steps. You need a GitHub account, Docker on your laptop, Node.js 20 or newer, Python 3.12, and one Linux server you can SSH into (Ubuntu is assumed). Any cloud VM or VPS will do; the server may cost money while it runs, so read *Cloud costs, free tiers and avoiding surprise bills* first, set a budget alert, and delete the server when you finish. A domain name is optional: without one the site runs over plain HTTP on the server's IP address.
How it fits together.
Architecture
| Piece | Job |
|---|---|
| GitHub Actions | Runs tests, builds and pushes images, then deploys |
| GHCR | Stores your Docker images (ghcr.io/<you>/brew-api, brew-web) |
| Server with Docker | Pulls the images and runs them with Compose |
| Caddy | Sits in front, gets an HTTPS certificate automatically when you have a domain |
| nginx (in the web image) | Serves the React build and forwards /api/ to the API |
Stage 1: the backend
We use a small in-memory API so the focus stays on delivery. It follows the shared Brew & Bean design, with an /api prefix so the frontend and backend can sit behind one address. The total is computed on the server, never trusted from the browser. Orders are kept in memory, so they vanish on restart: that is fine for this project and is one of the stretch goals.
You are a senior backend developer. Write a FastAPI app in one file `main.py` for a coffee shop called Brew & Bean.
Endpoints: GET /api/health, GET /api/menu, POST /api/orders (body: customer_name, items [{item_id, qty}]; the server computes the total in rupees from a fixed menu and returns the order with status 'received'), GET /api/orders/{id}.
Menu: espresso 120, cappuccino 180, cold-brew 220, filter-coffee 80, masala-chai 90, croissant 150, banana-bread 130, veg-sandwich 160.
Use Pydantic v2 validation (qty 1 to 20, at least one item), return 422 for unknown items and 404 for a missing order. Store orders in a dict. Then write pytest tests with FastAPI's TestClient.What to expect: Check: is the total computed from the menu on the server? Are unknown items rejected? Do the tests cover a happy path and two failures? Compare with the reference code below.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="Brew & Bean API")
MENU = {
"espresso": {"name": "Espresso", "category": "coffee", "price": 120},
"cappuccino": {"name": "Cappuccino", "category": "coffee", "price": 180},
"cold-brew": {"name": "Cold Brew", "category": "coffee", "price": 220},
"filter-coffee": {"name": "Filter Coffee", "category": "coffee", "price": 80},
"masala-chai": {"name": "Masala Chai", "category": "tea", "price": 90},
"croissant": {"name": "Butter Croissant", "category": "bakery", "price": 150},
"banana-bread": {"name": "Banana Bread", "category": "bakery", "price": 130},
"veg-sandwich": {"name": "Veg Grilled Sandwich", "category": "food", "price": 160},
}
ORDERS = {}
class OrderItem(BaseModel):
item_id: str
qty: int = Field(ge=1, le=20)
class OrderIn(BaseModel):
customer_name: str = Field(min_length=1, max_length=60)
items: list[OrderItem] = Field(min_length=1)
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.get("/api/menu")
def menu():
return [{"id": item_id, **data} for item_id, data in MENU.items()]
@app.post("/api/orders", status_code=201)
def create_order(order: OrderIn):
total = 0
for item in order.items:
if item.item_id not in MENU:
raise HTTPException(status_code=422, detail=f"Unknown item: {item.item_id}")
total += MENU[item.item_id]["price"] * item.qty
order_id = len(ORDERS) + 1
record = {
"id": order_id,
"customer_name": order.customer_name,
"items": [item.model_dump() for item in order.items],
"total": total,
"status": "received",
}
ORDERS[order_id] = record
return record
@app.get("/api/orders/{order_id}")
def get_order(order_id: int):
if order_id not in ORDERS:
raise HTTPException(status_code=404, detail="Order not found")
return ORDERS[order_id]The total is computed here from the server's own price list.
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_health():
response = client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_menu_has_eight_items():
response = client.get("/api/menu")
assert response.status_code == 200
assert len(response.json()) == 8
def test_order_total_is_computed_by_server():
body = {
"customer_name": "Asha",
"items": [{"item_id": "espresso", "qty": 2}, {"item_id": "croissant", "qty": 1}],
}
response = client.post("/api/orders", json=body)
assert response.status_code == 201
assert response.json()["total"] == 390 # 2 x 120 + 150
assert response.json()["status"] == "received"
def test_unknown_item_is_rejected():
body = {"customer_name": "Asha", "items": [{"item_id": "pizza", "qty": 1}]}
assert client.post("/api/orders", json=body).status_code == 422
def test_missing_order_is_404():
assert client.get("/api/orders/9999").status_code == 404fastapi
uvicorn-r requirements.txt
pytest
httpxhttpx is needed by FastAPI's TestClient. In a real project, pin versions (for example with pip freeze) so builds are repeatable.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Dependencies are copied and installed before the code, so Docker reuses that layer when only your code changes. The app runs as a non-root user.
Run the backend
- 1In the
backendfolder create a virtual environment, activate it and runpip install -r requirements-dev.txt. - 2Run
pytest -q. All five tests should pass. - 3Run
uvicorn main:app --reloadand openhttp://localhost:8000/api/menu.
..... [100%]
5 passed in 0.4sStage 2: the frontend
The frontend is a minimal React app (Vite, plain JavaScript) that lists the menu and lets you order one of any item. Your own Brew & Bean frontend from the Web Development track can replace it: all that matters is that it calls /api/... with relative URLs, so the same build works on every machine.
{
"name": "brew-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
}
}import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: { "/api": "http://localhost:8000" },
},
});During npm run dev the proxy forwards /api calls to your local backend, imitating what nginx does in production.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Brew & Bean</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(<App />);import { useEffect, useState } from "react";
export default function App() {
const [menu, setMenu] = useState([]);
const [message, setMessage] = useState("");
useEffect(() => {
fetch("/api/menu")
.then((res) => res.json())
.then(setMenu)
.catch(() => setMessage("Could not load the menu."));
}, []);
async function order(itemId) {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer_name: "Guest",
items: [{ item_id: itemId, qty: 1 }],
}),
});
const data = await res.json();
setMessage(
res.ok ? `Order #${data.id} received. Total: ₹${data.total}` : "Order failed."
);
}
return (
<main style={{ maxWidth: 480, margin: "2rem auto", fontFamily: "sans-serif" }}>
<h1>Brew & Bean</h1>
<p>Pickup orders. Open 8 am to 10 pm.</p>
<ul style={{ padding: 0, listStyle: "none" }}>
{menu.map((item) => (
<li key={item.id} style={{ display: "flex", justifyContent: "space-between", padding: "0.4rem 0" }}>
<span>{item.name} - ₹{item.price}</span>
<button onClick={() => order(item.id)}>Order</button>
</li>
))}
</ul>
{message && <p role="status">{message}</p>}
</main>
);
}server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
location / {
try_files $uri /index.html;
}
}Requests to /api/ go to the container named api on the Compose network. Everything else serves the React files, falling back to index.html so client-side routes work.
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/htmlA multi-stage build: Node builds the static files, and the final image contains only nginx and the build output, so it is small. npm ci needs a package-lock.json: run npm install once in frontend and commit the lock file.
Stage 3: run everything locally with Compose
services:
api:
build: ./backend
web:
build: ./frontend
ports:
- "8080:80"
depends_on:
- apiOnly web is published on your laptop; api is reachable only through nginx, as it will be on the server.
Test locally
- 1From the project root run
docker compose up --build. - 2Open
http://localhost:8080. You should see the menu. Click Order next to Espresso: the page showsOrder #1 received. Total: ₹120. - 3Open
http://localhost:8080/api/healthto confirm the API answers through nginx. - 4Stop with Ctrl+C and
docker compose down.
Local first
Do not move on until this works locally. Debugging a failing deployment is far harder than debugging a failing laptop run: every problem you fix here is one you will not meet in the pipeline.
Stage 4: prepare the server
You need a Linux server with a public IP. Create one with any provider (a small Ubuntu VM is enough; one GB of RAM is workable but tight). In the provider's firewall or security group, allow inbound ports 22 (SSH), 80 and 443. Do not open anything else.
Server set-up
- 1SSH in as the admin user your provider gave you and install Docker using the official instructions for Ubuntu on docs.docker.com (the Docker Engine and Compose plugin). Check with
docker --versionanddocker compose version. - 2Create a deploy user without a password:
sudo adduser --disabled-password --gecos "" deploy, then allow it to use Docker withsudo usermod -aG docker deploy. Note that membership of thedockergroup is effectively root access on that machine, so use a dedicated server for this project. - 3On your laptop create a key pair just for deployments:
ssh-keygen -t ed25519 -f deploy_key -C github-actions -N "". This makesdeploy_key(private) anddeploy_key.pub(public). - 4Put the public key on the server: as the admin user run
sudo mkdir -p /home/deploy/.ssh, paste the contents ofdeploy_key.pubinto/home/deploy/.ssh/authorized_keys, thensudo chown -R deploy:deploy /home/deploy/.ssh,sudo chmod 700 /home/deploy/.sshandsudo chmod 600 /home/deploy/.ssh/authorized_keys. - 5Test from your laptop:
ssh -i deploy_key deploy@SERVER_IP "docker ps". It should print an empty table, not an error. - 6Collect the server's host key so the pipeline can verify it is talking to your server:
ssh-keyscan -H SERVER_IP. Keep this output for the next stage.
Protect the private key
The private key deploy_key is a password to your server. Never commit it, never paste it in chat, and delete the local copy after you have saved it as a GitHub secret. Keep deploy_key out of your repository folder, or add it to .gitignore.
Stage 5: production Compose file and Caddy
On the server we do not build images. We pull the ones the pipeline pushed. The images are tagged with the Git commit, so you always know what is running and can go back to an earlier one.
services:
api:
image: ghcr.io/${GITHUB_OWNER}/brew-api:${TAG:-latest}
restart: unless-stopped
web:
image: ghcr.io/${GITHUB_OWNER}/brew-web:${TAG:-latest}
restart: unless-stopped
depends_on:
- api
caddy:
image: caddy:2
restart: unless-stopped
environment:
DOMAIN: ${DOMAIN:-:80}
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on:
- web
volumes:
caddy_data:restart: unless-stopped brings containers back after a reboot. ${TAG:-latest} uses the variable TAG or latest if it is not set. DOMAIN defaults to :80, which means plain HTTP on any address.
{$DOMAIN} {
reverse_proxy web:80
}If DOMAIN is a real domain name pointing to your server (for example shop.example.com), Caddy obtains and renews a free HTTPS certificate on its own. Create an A record at your DNS provider first. With the default :80 it serves plain HTTP.
Stage 6: the pipeline
Create .github/workflows/ci-cd.yml. It has four jobs. test and build-frontend run on every push and pull request. publish and deploy run only for pushes to main, and only if the first two pass. Each job has the minimum permissions it needs.
name: ci-cd
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: backend/requirements-dev.txt
- name: Install dependencies
working-directory: backend
run: pip install -r requirements-dev.txt
- name: Run tests
working-directory: backend
run: pytest -q
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install and build
working-directory: frontend
run: |
npm ci
npm run build
publish:
needs: [test, build-frontend]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Lowercase the owner name
run: echo "OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push API image
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: |
ghcr.io/${{ env.OWNER }}/brew-api:latest
ghcr.io/${{ env.OWNER }}/brew-api:${{ github.sha }}
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: |
ghcr.io/${{ env.OWNER }}/brew-web:latest
ghcr.io/${{ env.OWNER }}/brew-web:${{ github.sha }}
deploy:
needs: publish
runs-on: ubuntu-latest
environment: production
concurrency: production-deploy
steps:
- uses: actions/checkout@v4
- name: Lowercase the owner name
run: echo "OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- name: Set up SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Copy deployment files
env:
TARGET: deploy@${{ secrets.DEPLOY_HOST }}
run: |
ssh "$TARGET" "mkdir -p ~/brew"
scp compose.prod.yaml Caddyfile "$TARGET:~/brew/"
- name: Pull images and restart
env:
TARGET: deploy@${{ secrets.DEPLOY_HOST }}
TAG: ${{ github.sha }}
run: |
ssh "$TARGET" "cd ~/brew && export GITHUB_OWNER=$OWNER TAG=$TAG && docker compose -f compose.prod.yaml pull && docker compose -f compose.prod.yaml up -d --remove-orphans"
- name: Health check
env:
HOST: ${{ secrets.DEPLOY_HOST }}
run: curl -fsSL --retry 10 --retry-delay 5 --retry-connrefused "http://$HOST/api/health"Secrets reach shell commands through env: rather than being pasted into the script text, which avoids injection and accidental printing. concurrency stops two deploys running at once. The environment: production line lets you add a manual approval rule in GitHub if you want one. The health check follows redirects (-L) because Caddy redirects HTTP to HTTPS when you use a domain.
Pin note: @v4, @v5 and @v6 are major-version tags of well-known actions. These versions were current when this lesson was written; check each action's page on the GitHub Marketplace for newer majors. Pinning to a full commit SHA is stricter and is common in security-sensitive teams.
Stage 7: wire up GitHub and ship
Connect and deploy
- 1Create a new repository on GitHub (private is fine) and push the project:
git init,git add .,git commit -m "Brew & Bean",git branch -M main,git remote add origin <your repo URL>,git push -u origin main. Check first thatdeploy_key,node_modulesand.venvare not being committed (use a.gitignore). - 2In the repository go to Settings, Environments, and create an environment called
production. - 3Add three environment secrets to it:
DEPLOY_HOST(the server IP or domain),DEPLOY_SSH_KEY(the whole contents of thedeploy_keyfile, including the BEGIN and END lines) andDEPLOY_KNOWN_HOSTS(the output ofssh-keyscan -H SERVER_IP). - 4Push a commit to
main(or re-run the workflow from the Actions tab). Watch the four jobs run. The first run takes the longest because nothing is cached. - 5The first
deploywill fail while pulling images because GHCR packages are private by default. Open your GitHub profile, Packages, choosebrew-api, Package settings, and change visibility to public. Do the same forbrew-web, then re-run the failed job. (Alternatively keep them private and rundocker login ghcr.ioon the server with a token that has theread:packagesscope.) - 6Open
http://SERVER_IPin your browser. Order an espresso. You have deployed a full-stack app. - 7If you own a domain: create an A record to the server's IP, then on the server create
~/brew/.envwith the lineDOMAIN=shop.example.com(your domain) and run the workflow again. Caddy fetches the certificate and the site works on HTTPS.
What goes in an image
Public packages are visible to everyone. That is fine here because the images contain only the code of this demo app and no secrets. Never bake passwords, API keys or .env files into an image. If an app later needs secrets, store them in GitHub environment secrets or on the server, and pass them as environment variables at run time.
Debugging the pipeline
Common problems
| Symptom | Likely cause | What to check |
|---|---|---|
publish fails with permission denied | packages: write missing or repository settings restrict the token | The permissions block in the job; Settings, Actions, General, Workflow permissions |
| Image name error: repository name must be lowercase | Uppercase letters in your GitHub username | The OWNER=...,, lowercase step |
deploy fails with Permission denied (publickey) | Wrong key or wrong authorized_keys permissions | Re-do the key steps; test with ssh -i deploy_key deploy@IP |
Host key verification failed | DEPLOY_KNOWN_HOSTS empty or for another IP | Re-run ssh-keyscan -H and update the secret |
pull access denied on the server | Packages are private | Make them public or log the server in to GHCR |
| Health check keeps failing | Containers crashed or ports closed | On the server: docker compose -f compose.prod.yaml ps and logs; check the firewall for ports 80 and 443 |
| 502 from Caddy or nginx | api container is down | docker compose -f compose.prod.yaml logs api |
Useful commands on the server (as the deploy user, inside ~/brew): docker compose -f compose.prod.yaml ps shows what is running, docker compose -f compose.prod.yaml logs -f api follows the API logs, and docker image prune -f removes old unused images so the disk does not fill up.
You are a GitHub Actions expert. My workflow job [job name] failed. The workflow file is below, then the log of the failed step. Explain the root cause in plain English, show the smallest fix as a diff, and tell me how to confirm it. Do not suggest disabling security checks, and do not ask me to paste secrets.
<workflow>
[paste ci-cd.yml]
</workflow>
<log>
[paste the failed step's log, secrets removed]
</log>What to expect: The answer should point at the exact line of the log and the exact line of YAML involved. Check any new action name or input in its Marketplace page.
Stage 8: rollback and cleanup
Every deploy runs images tagged with a commit SHA. If a release is bad, the quickest fix is to go back to the previous version. On the server:
cd ~/brew
export GITHUB_OWNER=your-github-username-in-lowercase
export TAG=<previous commit SHA>
docker compose -f compose.prod.yaml pull
docker compose -f compose.prod.yaml up -dFind the earlier SHA in the GitHub Actions run list or git log. The proper fix then goes through a new commit and the pipeline. Use a revert commit so that main also returns to the good state.
When you finish the project, delete the server (or stop it, noting that disks and IP addresses may still bill), remove the DNS record, and delete the GHCR packages if you do not need them. Check the provider's billing page the next day to confirm nothing is still running.
What you built, and how to say it in an interview
You can now describe a complete delivery path: code in Git, automated tests on every change, reproducible Docker images with commit tags, a registry, a pipeline that deploys over SSH with least-privilege secrets, a reverse proxy with HTTPS, a health check after deploy, and a simple rollback. In an interview, walk through the flow in order, name one thing that went wrong while you built it and how you debugged it, and say what you would add next (monitoring, a database, blue-green or canary releases, infrastructure as code with Terraform). Those 'next steps' are covered in the other lessons of this module.
Try it: Stretch 1: add a database
Replace the in-memory dict with PostgreSQL. Add a db service with a named volume to both Compose files, pass the connection string as an environment variable, and store the password in a GitHub environment secret that the pipeline writes into a .env file on the server.
Try it: Stretch 2: add monitoring
Add a /metrics endpoint and run Prometheus and Grafana beside the app as in *Monitoring, logs and alerts*. Create an alert for an error rate above 5% and send it to your email or phone.
Try it: Stretch 3: provision the server with Terraform
Write Terraform that creates the server, firewall rules and SSH key on your provider, so the whole environment can be created with one command and destroyed with another.
Try it: Stretch 4: staging and approval
Add a second environment, staging, that deploys every push to main, and make production require a manual approval in GitHub before the deploy job runs.
Key takeaways
- A delivery pipeline is: test, build, package (image), publish (registry), deploy, verify, and be able to roll back.
- Tag images with the commit SHA so every running version is traceable and rollback is just pulling an earlier tag.
- Give deploy credentials the least privilege: a dedicated key and user, secrets only through GitHub secrets, and verified host keys.
- Keep the app's addresses relative (
/api) and let a reverse proxy route traffic, so the same build runs locally and in production. - Always check health after a deploy, and know the first three commands you will run when it fails:
ps,logs, and the pipeline log. - Delete practice servers and watch your billing page when the project is done.
Check yourself
Finishing the quiz marks the lesson as done.
1. Why are Docker images tagged with the Git commit SHA as well as latest?
2. In the pipeline, why does deploy have needs: publish, and publish have needs: [test, build-frontend]?
3. What is the safest way to give the pipeline access to your server?
4. The deploy job fails with pull access denied for your image on the server. What is the most likely cause?
5. Why does the React app call /api/menu rather than http://localhost:8000/api/menu?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.