Cloud & DevOps
ProjectIntermediate 60 min· Running in production · 16 of 16

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:

Project layout
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.yml

Prerequisites. 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

PieceJob
GitHub ActionsRuns tests, builds and pushes images, then deploys
GHCRStores your Docker images (ghcr.io/<you>/brew-api, brew-web)
Server with DockerPulls the images and runs them with Compose
CaddySits 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.

Prompt · Ask AI for the first draft
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.

backend/main.py
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.

backend/test_main.py
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 == 404
backend/requirements.txt
fastapi
uvicorn
backend/requirements-dev.txt
-r requirements.txt
pytest
httpx

httpx is needed by FastAPI's TestClient. In a real project, pin versions (for example with pip freeze) so builds are repeatable.

backend/Dockerfile
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

  1. 1In the backend folder create a virtual environment, activate it and run pip install -r requirements-dev.txt.
  2. 2Run pytest -q. All five tests should pass.
  3. 3Run uvicorn main:app --reload and open http://localhost:8000/api/menu.
pytest output (time will differ)
.....                                                                    [100%]
5 passed in 0.4s

Stage 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.

frontend/package.json
{
  "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"
  }
}
frontend/vite.config.js
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.

frontend/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Brew &amp; Bean</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
frontend/src/main.jsx
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";

createRoot(document.getElementById("root")).render(<App />);
frontend/src/App.jsx
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 &amp; 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>
  );
}
frontend/nginx.conf
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.

frontend/Dockerfile
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/html

A 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

compose.yaml
services:
  api:
    build: ./backend
  web:
    build: ./frontend
    ports:
      - "8080:80"
    depends_on:
      - api

Only web is published on your laptop; api is reachable only through nginx, as it will be on the server.

Test locally

  1. 1From the project root run docker compose up --build.
  2. 2Open http://localhost:8080. You should see the menu. Click Order next to Espresso: the page shows Order #1 received. Total: ₹120.
  3. 3Open http://localhost:8080/api/health to confirm the API answers through nginx.
  4. 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

  1. 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 --version and docker compose version.
  2. 2Create a deploy user without a password: sudo adduser --disabled-password --gecos "" deploy, then allow it to use Docker with sudo usermod -aG docker deploy. Note that membership of the docker group is effectively root access on that machine, so use a dedicated server for this project.
  3. 3On your laptop create a key pair just for deployments: ssh-keygen -t ed25519 -f deploy_key -C github-actions -N "". This makes deploy_key (private) and deploy_key.pub (public).
  4. 4Put the public key on the server: as the admin user run sudo mkdir -p /home/deploy/.ssh, paste the contents of deploy_key.pub into /home/deploy/.ssh/authorized_keys, then sudo chown -R deploy:deploy /home/deploy/.ssh, sudo chmod 700 /home/deploy/.ssh and sudo chmod 600 /home/deploy/.ssh/authorized_keys.
  5. 5Test from your laptop: ssh -i deploy_key deploy@SERVER_IP "docker ps". It should print an empty table, not an error.
  6. 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.

compose.prod.yaml
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.

Caddyfile
{$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.

.github/workflows/ci-cd.yml
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

  1. 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 that deploy_key, node_modules and .venv are not being committed (use a .gitignore).
  2. 2In the repository go to Settings, Environments, and create an environment called production.
  3. 3Add three environment secrets to it: DEPLOY_HOST (the server IP or domain), DEPLOY_SSH_KEY (the whole contents of the deploy_key file, including the BEGIN and END lines) and DEPLOY_KNOWN_HOSTS (the output of ssh-keyscan -H SERVER_IP).
  4. 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.
  5. 5The first deploy will fail while pulling images because GHCR packages are private by default. Open your GitHub profile, Packages, choose brew-api, Package settings, and change visibility to public. Do the same for brew-web, then re-run the failed job. (Alternatively keep them private and run docker login ghcr.io on the server with a token that has the read:packages scope.)
  6. 6Open http://SERVER_IP in your browser. Order an espresso. You have deployed a full-stack app.
  7. 7If you own a domain: create an A record to the server's IP, then on the server create ~/brew/.env with the line DOMAIN=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

SymptomLikely causeWhat to check
publish fails with permission deniedpackages: write missing or repository settings restrict the tokenThe permissions block in the job; Settings, Actions, General, Workflow permissions
Image name error: repository name must be lowercaseUppercase letters in your GitHub usernameThe OWNER=...,, lowercase step
deploy fails with Permission denied (publickey)Wrong key or wrong authorized_keys permissionsRe-do the key steps; test with ssh -i deploy_key deploy@IP
Host key verification failedDEPLOY_KNOWN_HOSTS empty or for another IPRe-run ssh-keyscan -H and update the secret
pull access denied on the serverPackages are privateMake them public or log the server in to GHCR
Health check keeps failingContainers crashed or ports closedOn the server: docker compose -f compose.prod.yaml ps and logs; check the firewall for ports 80 and 443
502 from Caddy or nginxapi container is downdocker 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.

Prompt · Explain my failing workflow
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:

Terminal 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 -d

Find 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

  1. A delivery pipeline is: test, build, package (image), publish (registry), deploy, verify, and be able to roll back.
  2. Tag images with the commit SHA so every running version is traceable and rollback is just pulling an earlier tag.
  3. Give deploy credentials the least privilege: a dedicated key and user, secrets only through GitHub secrets, and verified host keys.
  4. Keep the app's addresses relative (/api) and let a reverse proxy route traffic, so the same build runs locally and in production.
  5. Always check health after a deploy, and know the first three commands you will run when it fails: ps, logs, and the pipeline log.
  6. 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.

Start free