Build a Resilient URL Shortener

Build a load-balanced URL shortener with PostgreSQL and trusted local HTTPS.

Introduction

30 Second Summary

A short link feels simple. One missing record or one failed server can still make that link disappear.

In this project, you will build LinkLab, a URL shortener that runs in Docker Desktop and exposes its architecture in the browser. Each response identifies its serving replica plus the active storage mode.

What You'll Build

Picture clicking the short path /demo and reaching your chosen destination while LinkLab names the replica behind the response.

By the end of this project, you'll have:

  • A browser interface that turns destination URLs into memorable short paths you can open from the same page.
  • A visible load balancing demonstration where Caddy alternates requests between app1 and app2.
  • Reliable redirects from either replica through shared PostgreSQL storage, served from a trusted local HTTPS address after HTTP redirects automatically.
  • Secret Mission: Add health checks that keep the product available through app2 when app1 stops.

Are there any prerequisites?

You'll need a supported macOS release with at least 4 GB of RAM plus VS Code. Basic Python familiarity is enough because the project sets up every containerized dependency with you.

Before We Start

This checkpoint turns the project goal into a clear commitment before any hands-on work begins. You are building LinkLab to reveal how multiple servers, shared state, load balancing, and TLS shape a URL shortener.

Set Up the Container Lab

A repeatable multi-service lab needs one local runtime. Docker Desktop replaces separate host installations of Python, Caddy, and PostgreSQL.

Your Mac uses Docker Engine to build and run isolated containers. VS Code holds the pinned files that make the same environment reproducible throughout LinkLab.

In this step, get ready to:
  • Install Docker Desktop on a supported Mac.
  • Create the starter LinkLab workspace in VS Code.
  • Build the application image and verify its pinned dependencies.
Check and install Docker Desktop

Docker Desktop supports the current macOS release plus the two previous major releases. Your Mac also needs at least 4 GB of memory.

  • Click the Apple menu in the top-left corner of your screen.
  • Click About This Mac.
  • Confirm that the listed memory is at least 4 GB.
  • Confirm that the listed macOS version is the current release or one of the two previous major releases.
  • Read the Chip value to identify whether your Mac uses Apple silicon or Intel.

Does Your Mac Miss a Requirement?

Stop before installing Docker Desktop if the Mac has less than 4 GB of memory or runs an unsupported macOS release. Help me assess whether my Mac can run Docker Desktop.

Your Mac model determines which Docker Desktop installer you need. A quick Spotlight search also reveals whether the application is already present.

  • Press Cmd+Space to open Spotlight.
  • Type Docker.
  • Press Enter.

✔️ Docker Desktop opens

Docker Desktop is already installed. Wait until it reports that the engine is running before continuing.

ⓧ Docker Desktop is not installed

The installer places Docker Desktop at /Applications/Docker.app. macOS may request your password to authorize this installation.

  • Open the official Docker Desktop installation guide in your browser.
  • Select the installer that matches the Chip value you checked.
  • Open the downloaded Docker disk image.
  • Drag Docker into the Applications folder.
  • Press Cmd+Space to open Spotlight.
  • Type Docker.
  • Press Enter to start Docker Desktop.
  • Complete the setup prompts shown by Docker Desktop.

The first checks confirm that your terminal can reach both Docker Engine and the Docker Compose plugin.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal.
  • Press Enter to open Terminal.

Before you run the checks, do you expect both Docker components to answer while Docker Desktop is running?

  • Verify Docker Engine and Docker Compose by running these commands:
docker version
docker compose version

What Do These Checks Prove?

  • The first command asks Docker Engine for its client and server component versions.
  • The second command confirms that the Docker Compose plugin is available.

You will see client and server version details from Docker Engine. You will also see Docker Compose version information.

Docker Commands Not Responding?

Confirm that Docker Desktop is running before retrying the commands. If the engine is still starting, wait for Docker Desktop to report that it is ready.

Help me fix Docker Engine or Docker Compose on my Mac.

Create the starter workspace

The linklab workspace keeps the application source and container instructions together. Each pinned file gives the image an exact input.

  • Click Finder in the Dock.
  • Select Desktop in the Finder sidebar.
  • Click File in the menu bar.
  • Click New Folder.
  • Type linklab.
  • Press Enter.

You will see the new linklab folder on your Desktop.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code.
  • Press Enter to start VS Code.
  • Click File in the VS Code menu bar.
  • Click Open Folder.
  • Select the linklab folder on your Desktop.
  • Click Open.

The Explorer sidebar now shows the empty linklab workspace.

  • Click the New Folder icon in the Explorer sidebar.
  • Type templates.
  • Press Enter.

You will see the templates folder inside linklab.

The requirements file pins Flask at 3.1.3 and Psycopg at 3.3.6. These pins keep the container build reproducible.

  • Select the linklab folder in the Explorer sidebar.
  • Click the New File icon.
  • Type requirements.txt.
  • Press Enter.
  • Add the pinned dependencies by pasting this code:
Flask==3.1.3
psycopg[binary]==3.3.6

What Does This File Do?

  • The Flask requirement provides the web framework used by LinkLab.
  • The Psycopg binary extra provides the PostgreSQL client without requiring local build tools.
  • The exact versions prevent a later package release from silently changing this lab.
  • Save requirements.txt by pressing Cmd+S on macOS or Ctrl+S on Windows.

The Explorer sidebar shows requirements.txt inside linklab.

Requirements File in the Wrong Folder?

Move requirements.txt until it sits directly inside linklab. It must remain outside the templates folder.

Help me place requirements.txt correctly in my VS Code workspace.

The starter Python application provides a small route that proves the image can launch Flask. This creates a runnable foundation before the URL-shortening routes arrive.

  • Select the linklab folder in the Explorer sidebar.
  • Click the New File icon.
  • Type app.py.
  • Press Enter.
  • Add the starter Flask application by pasting this code:
from flask import Flask

app = Flask(__name__)


@app.get("/")
def index():
    return "LinkLab is ready."


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)

What Does This Code Do?

  • The Flask application receives web requests inside the container.
  • The index() route returns a visible readiness message at the home path.
  • The server listens on port 8000 across the container network.
  • Save app.py by pressing Cmd+S on macOS or Ctrl+S on Windows.

The Explorer sidebar now lists app.py beside requirements.txt.

Seeing Python Syntax Highlighting Problems?

Confirm that the filename is exactly app.py. Check that every quote and parenthesis matches the reference code.

Help me compare my starter app.py with the LinkLab reference.

The starter HTML template gives LinkLab a document shell for the browser interface. Flask finds templates inside the templates folder.

  • Select the templates folder in the Explorer sidebar.
  • Click the New File icon.
  • Type index.html.
  • Press Enter.
  • Add the starter page by pasting this code:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>LinkLab</title>
</head>
<body>
  <h1>LinkLab URL Shortener</h1>
</body>
</html>

What Does This Page Define?

  • The document metadata identifies the page as LinkLab.
  • The heading gives the future browser interface a clear product title.
  • The file location allows Flask to load the page as a template.
  • Save templates/index.html by pressing Cmd+S on macOS or Ctrl+S on Windows.

The Explorer sidebar shows index.html nested inside templates.

Template Missing from Its Folder?

Drag index.html into templates if it appears at the same level as app.py. The final path must be templates/index.html.

Help me fix the templates/index.html path in VS Code.

The Dockerfile turns the workspace into an image. It starts from pinned Python and installs the pinned requirements before copying the application files.

  • Select the linklab folder in the Explorer sidebar.
  • Click the New File icon.
  • Type Dockerfile.
  • Press Enter.
  • Add the image instructions by pasting this code:
FROM python:3.14.8-slim

WORKDIR /app

COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt

COPY app.py .
COPY templates ./templates

EXPOSE 8000

CMD ["python", "app.py"]

What Does This Dockerfile Do?

  • The base image supplies Python 3.14.8 in a slim container.
  • The install layer reads requirements.txt before copying the application source.
  • The copy instructions place app.py and the templates inside /app.
  • The final instruction starts LinkLab when a container launches from the image.
  • Save Dockerfile by pressing Cmd+S on macOS or Ctrl+S on Windows.

The Explorer sidebar now shows all four starter files plus the templates folder.

Dockerfile Showing as Plain Text?

Confirm that the filename is exactly Dockerfile. Remove any hidden .txt extension before building the image.

Help me check my Dockerfile name and location.

✔️ Awesome, I've got everything!

  • Save every open file in VS Code.
  • Confirm that the Explorer sidebar lists requirements.txt, app.py, Dockerfile, and templates/index.html.

ⓧ I'd like to double check the full code

Compare each saved file with these complete starter versions.

Flask==3.1.3
psycopg[binary]==3.3.6
from flask import Flask

app = Flask(__name__)


@app.get("/")
def index():
    return "LinkLab is ready."


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>LinkLab</title>
</head>
<body>
  <h1>LinkLab URL Shortener</h1>
</body>
</html>
FROM python:3.14.8-slim

WORKDIR /app

COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt

COPY app.py .
COPY templates ./templates

EXPOSE 8000

CMD ["python", "app.py"]
Build and verify the container image

Building linklab-app proves that the Dockerfile and every copied path work together. Pulling the pinned Caddy and PostgreSQL images prepares the remaining services without installing either tool on your Mac.

  • Click Terminal in the VS Code menu bar.
  • Click New Terminal.

The integrated terminal opens inside the linklab workspace. The first build can take several minutes while Docker downloads the Python base image and installs the dependencies.

  • Build the LinkLab image and download the two service images by running these commands:
docker build -t linklab-app .
docker pull caddy:2.11.7-alpine
docker pull postgres:18.6-alpine

What Do These Commands Prepare?

  • The build command reads the local Dockerfile and tags the result as linklab-app.
  • The first pull downloads caddy:2.11.7-alpine for later load balancing and HTTPS.
  • The second pull downloads postgres:18.6-alpine for later shared storage.
  • Confirm that the linklab-app build completes without an error.
  • Confirm that the Caddy image pull finishes successfully.
  • Confirm that the PostgreSQL image pull finishes successfully.

Image Build or Pull Failed?

Confirm that Docker Desktop is running. Check that the integrated terminal is inside linklab and that Dockerfile sits beside requirements.txt.

Help me debug my LinkLab Docker build or image pull.

The final check separates host readiness from image readiness. Docker Engine and Compose run on your Mac while Flask and Psycopg import inside a temporary container.

Before you run these checks, do you expect the dependency import to succeed inside the image?

  • Verify the complete container lab by running these commands:
docker version
docker compose version
docker run --rm linklab-app python -c "import flask, psycopg; print('Flask and Psycopg imports succeeded.')"

What Does the Final Check Prove?

  • The Docker version check proves that the command line can communicate with the running engine.
  • The Compose version check proves that multi-service orchestration is available.
  • The temporary container imports both pinned Python dependencies from linklab-app.
  • The removal option deletes the temporary dependency-check container after it exits.

You will see client and server version details from Docker Engine. You will also see Docker Compose version information.

The final line says Flask and Psycopg imports succeeded. This confirms that the image contains working installations of both pinned packages.

That is the lab foundation complete. Your Mac can now build the LinkLab image with the same dependencies every time.

Dependency Imports Failed?

Check that requirements.txt matches the reference file. Rebuild linklab-app after correcting any filename or version differences.

Help me debug the Flask or Psycopg import check inside linklab-app.

Your container lab is ready. Next, you will turn this starter image into a working single-instance URL shortener.

Build a Single-Instance URL Shortener

Your container lab can already build a Flask image. It now needs a product you can use before you introduce more servers.

A single process gives every request access to the same in-memory dictionary. In this step, you will turn that simple boundary into a working URL shortener at http://localhost:8001.

In this step, get ready to:
  • Turn the starter Flask route into an in-memory URL shortener.
  • Run one app instance as app1 on port 8001.
  • Prove the demo short path redirects to its destination.
Run the starter through app1

Docker Compose gives the container a repeatable identity. This first service maps port 8001 on your Mac to port 8000 inside app1.

  • In the VS Code file sidebar, select the linklab folder.
  • Use the new-file control to create compose.yml inside linklab.
  • Paste this service definition into compose.yml:
services:
  app1:
    build: .
    environment:
      INSTANCE_NAME: app1
      STORAGE_MODE: memory
    ports:
      - "8001:8000"

What does this configuration do?

  • The build setting creates the application image from the existing Dockerfile.
  • INSTANCE_NAME gives this process the visible identity app1.
  • STORAGE_MODE records that this version stores links in memory.
  • The port mapping sends requests from your Mac on 8001 to the Flask server on 8000.
  • Save compose.yml.
  • Return to the terminal from Step 1.
  • Start the starter application as app1 by running this command:
docker compose up --build -d app1

What does this command do?

Docker Compose builds the current application image. It starts app1 in the background.

  • Enter http://localhost:8001 in your browser's address bar.

You should see LinkLab is ready. Good, app1 is now serving browser requests through the container port mapping.

Does the starter page fail to load?

  • Confirm Docker Desktop is still running.
  • Confirm the terminal is using the linklab folder.
  • Check that compose.yml maps 8001:8000.

Help me diagnose why app1 does not load at localhost.

Build the in-memory Flask app

The shortener needs one place to save each code while the process is running. The MEMORY_LINKS dictionary provides that temporary state for this single-instance baseline.

  • In app.py, select from from flask import Flask through app = Flask(__name__).
  • Replace that selection with this application foundation:
import os

from flask import Flask, redirect, render_template, request

app = Flask(__name__)

INSTANCE_NAME = os.getenv("INSTANCE_NAME", "local")
STORAGE_MODE = os.getenv("STORAGE_MODE", "memory")
MEMORY_LINKS = {}


def save_link(code, target_url):
    MEMORY_LINKS[code] = target_url


def load_link(code):
    return MEMORY_LINKS.get(code)


def render_home(short_path=None, error=None, code="", target_url=""):
    return render_template(
        "index.html",
        instance_name=INSTANCE_NAME,
        storage_mode=STORAGE_MODE,
        short_path=short_path,
        error=error,
        code=code,
        target_url=target_url,
    )

How does the application foundation work?

  • INSTANCE_NAME reads the serving process name from the container environment.
  • STORAGE_MODE gives the page a visible label for the current storage design.
  • save_link() stores a destination under its short code.
  • load_link() retrieves the destination for a requested code.
  • render_home() sends application values into the page template.
  • In app.py, replace the existing index() function with this route:
@app.get("/")
def index():
    return render_home()

What changes at the home route?

The home route now renders templates/index.html. This gives the browser access to the application values prepared by render_home().

  • Save app.py.
  • Rebuild the image with the new home route by running this command:
docker compose up --build -d app1

Why rebuild the service?

The application code is copied into the image by the Dockerfile. Rebuilding places the saved app.py inside the new container.

  • Refresh http://localhost:8001 in your browser.

You should now see the starter LinkLab URL Shortener heading. This proves the home route can render the template from inside app1.

Still seeing the starter message?

  • Confirm app.py is saved.
  • Confirm index() returns render_home().
  • Run the build command again from the linklab folder.

Help me find why my Flask container still serves the old starter response.

An HTTP POST route receives the two form fields. It validates both values before saving the mapping.

  • In app.py, paste this route below index().
@app.post("/shorten")
def shorten():
    code = request.form["code"].strip()
    target_url = request.form["url"].strip()

    valid_code = 3 <= len(code) <= 32 and all(
        character.isascii()
        and (character.isalnum() or character == "-")
        for character in code
    )
    if not valid_code:
        return render_home(
            error="Use 3 to 32 ASCII letters, numbers, or hyphens for the code.",
            code=code,
            target_url=target_url,
        ), 400

    if not target_url.startswith(("http://", "https://")):
        return render_home(
            error="The destination must begin with http:// or https://.",
            code=code,
            target_url=target_url,
        ), 400

    save_link(code, target_url)
    return render_home(
        short_path=f"/{code}",
        code=code,
        target_url=target_url,
    )

How does shortening work?

  • request.form reads the short code plus destination submitted by the page.
  • valid_code accepts between 3 and 32 ASCII letters, numbers, or hyphens.
  • The destination check accepts full HTTP or HTTPS URLs.
  • save_link() records a valid mapping in MEMORY_LINKS.
  • short_path gives the page a link that the learner can follow.
  • In app.py, paste these lookup routes below shorten().
@app.get("/debug/<code>")
def debug_link(code):
    target_url = load_link(code)
    return {
        "instance": INSTANCE_NAME,
        "storage": STORAGE_MODE,
        "code": code,
        "found": target_url is not None,
    }


@app.get("/health")
def health():
    return {"status": "ok", "instance": INSTANCE_NAME}


@app.get("/<code>")
def follow_link(code):
    target_url = load_link(code)
    if target_url is None:
        return "Short link not found.", 404
    return redirect(target_url)

What do the lookup routes provide?

  • debug_link() reports which instance handled a lookup.
  • health() provides a small status response for the application process.
  • follow_link() loads the destination for a short code.
  • redirect() sends a successful lookup to its saved destination.
  • Save app.py.
  • Rebuild app1 with the new routes by running this command:
docker compose up --build -d app1

What changes in the container?

The rebuilt image now contains the form handler, debug response, health response, and redirect route. The restarted app1 process serves all four routes.

  • Enter http://localhost:8001/debug/demo in your browser's address bar.

You should see app1 as the instance. You should also see memory as the storage mode plus false for found.

Does the debug route fail?

  • Confirm the debug route appears above the final if __name__ == "__main__": block.
  • Confirm app.py is saved.
  • Rebuild app1 from the linklab folder.

Help me debug a missing Flask debug route in my app1 container.

The Jinja template turns these routes into a browser interface. It also displays the serving instance plus storage mode above the form.

  • In templates/index.html, select all of the existing starter markup.
  • Replace the selection with this page head:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="data:,">
  <title>LinkLab</title>
  <style>
    :root { color-scheme: light; font-family: system-ui, sans-serif; background: #eef2ff; color: #172033; }
    body { margin: 0; min-height: 100vh; display: grid; place-items: center; }
    main { width: min(42rem, calc(100% - 2rem)); background: white; border-radius: 1rem; padding: 2rem; box-shadow: 0 1rem 3rem rgba(30, 41, 59, 0.15); }
    form { display: grid; gap: 1rem; }
    label { display: grid; gap: 0.35rem; font-weight: 650; }
    input, button { font: inherit; padding: 0.8rem; border-radius: 0.55rem; }
    input { border: 1px solid #a5b4fc; }
    button { border: 0; background: #4338ca; color: white; cursor: pointer; }
    .architecture { color: #475569; }
    .result, .error { margin-top: 1rem; padding: 1rem; border-radius: 0.55rem; }
    .result { background: #dcfce7; }
    .error { background: #fee2e2; }
  </style>
</head>

What does the page head define?

The metadata keeps the form readable across browser sizes. The styles center the interface in a card with distinct result and error states.

  • Paste this page body directly below </head>.
<body>
  <main>
    <h1>LinkLab URL Shortener</h1>
    <p class="architecture">
      Served by <strong>{{ instance_name }}</strong>
      using <strong>{{ storage_mode }}</strong> storage.
    </p>
    <form method="post" action="/shorten">
      <label>
        Short code
        <input name="code" value="{{ code }}" minlength="3" maxlength="32" pattern="[A-Za-z0-9-]+" required>
      </label>
      <label>
        Destination URL
        <input name="url" type="url" value="{{ target_url }}" placeholder="Paste a full HTTP or HTTPS URL" required>
      </label>
      <button type="submit">Create short link</button>
    </form>
    {% if short_path %}
      <p class="result">Your short link is <a href="{{ short_path }}">{{ short_path }}</a>.</p>
    {% endif %}
    {% if error %}
      <p class="error">{{ error }}</p>
    {% endif %}
  </main>
</body>
</html>

How does the page use Flask values?

  • instance_name shows which application process served the page.
  • storage_mode exposes the current storage design.
  • The form submits code plus url to /shorten.
  • short_path displays a clickable result after a successful save.
  • error displays a specific validation message when an input fails.
  • Save templates/index.html.
  • Rebuild app1 with the finished page by running this command:
docker compose up --build -d app1

What does this rebuild include?

The image now includes the finished app.py plus templates/index.html. The recreated container serves the complete single-instance product.

  • Return to http://localhost:8001 in your browser.

You should see a centered LinkLab form with fields for a short code plus destination URL. The architecture line should say it is served by app1 using memory storage.

Is the form missing?

  • Confirm templates/index.html is saved inside the templates folder.
  • Confirm the body markup sits below </head>.
  • Rebuild app1 after saving the template.

Help me find why my Flask container still shows the starter template.

✔️ Awesome, I've got everything!

Your LinkLab files now match the single-instance baseline. The application is ready for its first short link.

ⓧ I'd like to double check the full code

The full app.py should be:

import os

from flask import Flask, redirect, render_template, request

app = Flask(__name__)

INSTANCE_NAME = os.getenv("INSTANCE_NAME", "local")
STORAGE_MODE = os.getenv("STORAGE_MODE", "memory")
MEMORY_LINKS = {}


def save_link(code, target_url):
    MEMORY_LINKS[code] = target_url


def load_link(code):
    return MEMORY_LINKS.get(code)


def render_home(short_path=None, error=None, code="", target_url=""):
    return render_template(
        "index.html",
        instance_name=INSTANCE_NAME,
        storage_mode=STORAGE_MODE,
        short_path=short_path,
        error=error,
        code=code,
        target_url=target_url,
    )


@app.get("/")
def index():
    return render_home()


@app.post("/shorten")
def shorten():
    code = request.form["code"].strip()
    target_url = request.form["url"].strip()

    valid_code = 3 <= len(code) <= 32 and all(
        character.isascii()
        and (character.isalnum() or character == "-")
        for character in code
    )
    if not valid_code:
        return render_home(
            error="Use 3 to 32 ASCII letters, numbers, or hyphens for the code.",
            code=code,
            target_url=target_url,
        ), 400

    if not target_url.startswith(("http://", "https://")):
        return render_home(
            error="The destination must begin with http:// or https://.",
            code=code,
            target_url=target_url,
        ), 400

    save_link(code, target_url)
    return render_home(
        short_path=f"/{code}",
        code=code,
        target_url=target_url,
    )


@app.get("/debug/<code>")
def debug_link(code):
    target_url = load_link(code)
    return {
        "instance": INSTANCE_NAME,
        "storage": STORAGE_MODE,
        "code": code,
        "found": target_url is not None,
    }


@app.get("/health")
def health():
    return {"status": "ok", "instance": INSTANCE_NAME}


@app.get("/<code>")
def follow_link(code):
    target_url = load_link(code)
    if target_url is None:
        return "Short link not found.", 404
    return redirect(target_url)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)

What to compare in app.py

Check the environment values, memory helpers, rendering helper, and five routes. The final server block should still listen on port 8000.

The full templates/index.html should be:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="data:,">
  <title>LinkLab</title>
  <style>
    :root { color-scheme: light; font-family: system-ui, sans-serif; background: #eef2ff; color: #172033; }
    body { margin: 0; min-height: 100vh; display: grid; place-items: center; }
    main { width: min(42rem, calc(100% - 2rem)); background: white; border-radius: 1rem; padding: 2rem; box-shadow: 0 1rem 3rem rgba(30, 41, 59, 0.15); }
    form { display: grid; gap: 1rem; }
    label { display: grid; gap: 0.35rem; font-weight: 650; }
    input, button { font: inherit; padding: 0.8rem; border-radius: 0.55rem; }
    input { border: 1px solid #a5b4fc; }
    button { border: 0; background: #4338ca; color: white; cursor: pointer; }
    .architecture { color: #475569; }
    .result, .error { margin-top: 1rem; padding: 1rem; border-radius: 0.55rem; }
    .result { background: #dcfce7; }
    .error { background: #fee2e2; }
  </style>
</head>
<body>
  <main>
    <h1>LinkLab URL Shortener</h1>
    <p class="architecture">
      Served by <strong>{{ instance_name }}</strong>
      using <strong>{{ storage_mode }}</strong> storage.
    </p>
    <form method="post" action="/shorten">
      <label>
        Short code
        <input name="code" value="{{ code }}" minlength="3" maxlength="32" pattern="[A-Za-z0-9-]+" required>
      </label>
      <label>
        Destination URL
        <input name="url" type="url" value="{{ target_url }}" placeholder="Paste a full HTTP or HTTPS URL" required>
      </label>
      <button type="submit">Create short link</button>
    </form>
    {% if short_path %}
      <p class="result">Your short link is <a href="{{ short_path }}">{{ short_path }}</a>.</p>
    {% endif %}
    {% if error %}
      <p class="error">{{ error }}</p>
    {% endif %}
  </main>
</body>
</html>

What to compare in the template

Check the architecture line, form fields, result message, and error message. Each template value should use the same identifier passed by render_home().

The full compose.yml should be:

services:
  app1:
    build: .
    environment:
      INSTANCE_NAME: app1
      STORAGE_MODE: memory
    ports:
      - "8001:8000"

What to compare in compose.yml

Check that only app1 exists in this version. Its instance name, storage mode, and port mapping should match the application page.

The unchanged requirements.txt should be:

Flask==3.1.3
psycopg[binary]==3.3.6

What to compare in requirements.txt

The dependency pins stay unchanged from Step 1. Flask serves this version while Psycopg remains available for the shared-storage stage.

The unchanged Dockerfile should be:

FROM python:3.14.8-slim

WORKDIR /app

COPY requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt

COPY app.py .
COPY templates ./templates

EXPOSE 8000

CMD ["python", "app.py"]

What to compare in the Dockerfile

The image still installs the pinned requirements. It copies the finished application plus template before starting app.py.

Create and follow your first short link

The running process now has every part of the shortening loop. Creating demo writes one entry into the memory owned by app1.

  • Return to http://localhost:8001 in your browser.
  • Enter demo in the Short code field.
  • Enter a full URL that begins with http:// or https:// in the Destination URL field.
  • Click Create short link.

You should see the short path /demo in a green result box. The architecture line should still show app1 plus memory.

Before you follow /demo, where do you expect the browser to take you?

  • Click the /demo short path in the result box.

Your destination page opens. You now have a working short link backed by one in-memory Flask process.

  • Return to http://localhost:8001 for the final check.

You should still see app1 using memory storage. Every request in this test reaches the same process.

Your single-instance baseline is complete. Next, you will place two replicas behind Caddy to see how the design behaves under load balancing.

Load Balance Two Instances

Your single-instance LinkLab proved that Flask can create short links from memory. Every request still reaches one process.

Horizontal scaling creates another interchangeable application process. Caddy becomes the load balancer that rotates requests between the replicas.

In this step, get ready to:
  • Create a Caddy round-robin routing policy.
  • Run a second in-memory app replica through Docker Compose.
  • Prove that local memory returns inconsistent results behind the load balancer.
Define round-robin routing

The Caddyfile defines the public address for Caddy. Its reverse proxy sends each new request to the next application replica.

  • In the VS Code Explorer sidebar, create a file named Caddyfile inside the linklab folder.
  • Set the HTTP site address with round-robin upstreams by pasting this code into Caddyfile:
http://localhost:8080 {
    reverse_proxy app1:8000 app2:8000 {
        lb_policy round_robin
    }
}

What does this configuration do?

  • The site address keeps this stage on HTTP at local port 8080.
  • The reverse_proxy directive targets both app services at container port 8000.
  • The lb_policy round_robin setting rotates requests through the upstream list in order.
  • Save Caddyfile.
  • Confirm Caddyfile is listed beside compose.yml in the VS Code Explorer sidebar.

Caddyfile missing from the sidebar?

  • Confirm the file sits directly inside the linklab folder.
  • Check that the filename is exactly Caddyfile with no file extension.

Help me check my Caddyfile location and filename.

Add the second replica

Both app services build from the same Dockerfile. Separate INSTANCE_NAME values make the serving replica visible in each response.

Only app1 keeps a direct host port. Caddy reaches both replicas through their service names on the private Compose network.

  • In the VS Code Explorer sidebar, select compose.yml.
  • Replace the full contents of compose.yml with this configuration:
services:
  app1:
    build: .
    environment:
      INSTANCE_NAME: app1
      STORAGE_MODE: memory
    ports:
      - "8001:8000"

  app2:
    build: .
    environment:
      INSTANCE_NAME: app2
      STORAGE_MODE: memory

  caddy:
    image: caddy:2.11.7-alpine
    ports:
      - "8080:8080"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    depends_on:
      - app1
      - app2

How does the stack fit together?

  • The app2 service builds the same LinkLab image with its own runtime memory.
  • The caddy:2.11.7-alpine image runs the routing policy from Caddyfile.
  • The app1 port keeps the direct creation path available at http://localhost:8001.
  • The Caddy port exposes the load-balanced path at http://localhost:8080.
  • Save compose.yml.

The first start downloads the Caddy image when it is missing locally. A short pause in terminal output is expected.

  • Recreate the stack from the terminal you used earlier by running this command:
docker compose up --build -d

What does this command do?

Docker Compose rebuilds the LinkLab image from the current files. It starts all three services in the background.

  • Enter http://localhost:8080 in your browser address bar.
  • Refresh the LinkLab page several times.

You'll see the serving instance alternate between app1 and app2. The storage mode remains memory.

Caddy page not loading?

  • Confirm Docker Desktop is still running.
  • Check that compose.yml publishes Caddy port 8080.
  • Confirm the Caddyfile mount path matches the filename in your Explorer sidebar.

Help me debug why LinkLab is not loading through Caddy.

Expose the local-state failure

The architecture now contains two independent Flask processes. This test checks whether a mapping created by one replica is available to either replica.

  • Return to the browser tab from earlier.
  • Enter http://localhost:8001 in the address bar.
  • Enter demo in the Short code field.
  • Enter the same full destination URL you used earlier in the Destination URL field.
  • Click Create short link.

You should see the short path /demo while the page identifies app1 as the serving instance. The mapping now exists in app1's memory.

Before you test the load-balanced lookup, do you expect both replicas to report the same value for found?

  • Enter http://localhost:8080/debug/demo in the browser address bar.
  • Refresh the debug response several times.

You'll see instance alternate between app1 and app2. At the same time, found flips between true and false.

Why does found change?

Each Flask process owns a separate MEMORY_LINKS dictionary. The direct form submission stored demo only inside app1.

Caddy distributes requests correctly across both replicas. The application remains a stateful service because each response depends on one process's local history.

You caught the failure exactly where distributed systems hide it. LinkLab now proves why replica-local memory cannot support shared traffic.

✔️ Awesome, I've got everything!

Great work. Your stack now runs two separate app replicas behind Caddy's round-robin load balancer.

ⓧ I'd like to double check the full code

  • Compare your complete compose.yml with this reference:
services:
  app1:
    build: .
    environment:
      INSTANCE_NAME: app1
      STORAGE_MODE: memory
    ports:
      - "8001:8000"

  app2:
    build: .
    environment:
      INSTANCE_NAME: app2
      STORAGE_MODE: memory

  caddy:
    image: caddy:2.11.7-alpine
    ports:
      - "8080:8080"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    depends_on:
      - app1
      - app2

What should match?

This file defines two memory-backed app services. It also exposes Caddy on local port 8080.

  • Compare your complete Caddyfile with this reference:
http://localhost:8080 {
    reverse_proxy app1:8000 app2:8000 {
        lb_policy round_robin
    }
}

What should match?

This file keeps the entry point on HTTP. It rotates requests between the two private app upstreams.

Your two-replica lab now exposes the cost of storing shared product data in local memory. Next, you'll move each mapping into PostgreSQL so either replica can answer every request.

Make the App Stateless with PostgreSQL

Your load balancer exposed the weakness in local memory. A mapping saved by one replica stayed invisible to the other replica.

A shared PostgreSQL database gives both replicas one source of truth. Psycopg connects each Flask process to that shared data. This makes the application replicas stateless.

In this step, get ready to:
  • Connect the application to PostgreSQL with parameterized queries.
  • Add the database service and persistent storage to Compose.
  • Prove that both replicas can retrieve the same short link.
Connect the app to PostgreSQL

Parameterized SQL queries keep values separate from query structure. The application also needs an initialization function so every replica can safely confirm that the shared table exists.

  • In app.py, find import os near the top of the file.
  • Add the Psycopg import directly below it:
import psycopg

What does this import do?

The psycopg module provides the database connection used by the application. It is already installed in your container from requirements.txt.

  • Find MEMORY_LINKS = {} in app.py.
  • Add the database connection helper below it by copying this code:
def database_connection():
    return psycopg.connect(
        host=os.getenv("DB_HOST"),
        dbname=os.getenv("DB_NAME"),
        user=os.getenv("DB_USER"),
        password=os.getenv("DB_PASSWORD"),
    )

What does this helper do?

  • The environment variables hold the database host and database name.
  • The remaining environment variables hold the database user and local password.
  • The helper returns a fresh connection for each database operation.
  • Save app.py.
  • Rebuild the current memory-mode replicas by running this command:
docker compose up --build -d

What does this check prove?

Compose rebuilds the application image with the Psycopg import and connection helper. The running page confirms that the new database code has not broken the existing memory path.

  • Refresh http://localhost:8001 in your browser.

You should still see the LinkLab form served by app1 with memory storage.

Does the application fail to rebuild?

Check that import psycopg sits on its own line. Confirm that the connection helper remains outside every route function.

Compare the indentation around database_connection() with the snippet above.

Ask for help with the database helper.

Each replica needs to create the table before it serves database-backed requests. CREATE TABLE IF NOT EXISTS makes this initialization safe when both replicas start against the same database.

  • Add the initialization function below database_connection() in app.py by copying this code:
def initialize_database():
    if STORAGE_MODE != "postgres":
        return

    with database_connection() as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS links (
                code text PRIMARY KEY,
                target_url text NOT NULL
            )
            """
        )

What does initialization create?

  • The mode check keeps the original in-memory path available.
  • The links table stores one destination URL for each short code.
  • The primary key guarantees that each code identifies only one row.
  • Find the end of render_home() in app.py.
  • Add this initialization call above the first route decorator:
initialize_database()

Why call initialization here?

The call runs once when each application process starts. Memory mode returns immediately from the function.

  • Save app.py.
  • Rebuild the current services by running this command:
docker compose up --build -d

What does this run confirm?

The containers start successfully with the initialization call in place. Memory mode skips the database connection until Compose switches the storage setting.

  • Refresh http://localhost:8001.

You should see the LinkLab form again. Its architecture line should still identify app1 and memory storage.

Does the container restart repeatedly?

Confirm that initialize_database() appears after the function definitions. Check that it appears before the first route decorator.

Ask for help with application startup.

The storage helpers now need two paths. Memory mode keeps using the local dictionary. PostgreSQL mode writes to the shared table with an atomic insert or update.

  • Find the existing save_link() function in app.py.
  • Replace the entire function with this version:
def save_link(code, target_url):
    if STORAGE_MODE == "memory":
        MEMORY_LINKS[code] = target_url
        return

    with database_connection() as connection:
        connection.execute(
            """
            INSERT INTO links (code, target_url)
            VALUES (%s, %s)
            ON CONFLICT (code)
            DO UPDATE SET target_url = EXCLUDED.target_url
            """,
            (code, target_url),
        )

How does saving work?

  • Memory mode saves the mapping in MEMORY_LINKS as before.
  • PostgreSQL mode supplies the code and destination separately from the query.
  • The conflict rule updates the destination when the same code already exists.
  • Find the existing load_link() function in app.py.
  • Replace the entire function with this version:
def load_link(code):
    if STORAGE_MODE == "memory":
        return MEMORY_LINKS.get(code)

    with database_connection() as connection:
        row = connection.execute(
            "SELECT target_url FROM links WHERE code = %s",
            (code,),
        ).fetchone()

    return row[0] if row else None

How does loading work?

  • Memory mode reads from the local dictionary.
  • PostgreSQL mode selects the destination that matches the supplied code.
  • The function returns None when the database has no matching row.
  • Save app.py.
  • Rebuild the memory-mode services by running this command:
docker compose up --build -d

What does this rebuild check?

The rebuilt application contains both storage paths. The current Compose settings still select memory mode.

  • Refresh http://localhost:8001.

You should see the URL shortener load successfully. This confirms the original memory branch still works after the storage helpers changed.

Seeing a Python error after rebuilding?

Check that the parameter tuples keep their trailing commas. Confirm that the triple quotes around the insert query stay balanced.

Ask for help with the storage functions.

Add the shared database to Compose

A persistent volume keeps database files outside the life of one container. A health check prevents the application replicas from starting before PostgreSQL can accept connections.

  • In compose.yml, replace the opening services: line with this database service definition:
services:
  db:
    image: postgres:18.6-alpine
    environment:
      POSTGRES_DB: linklab
      POSTGRES_USER: linklab
      POSTGRES_PASSWORD: linklab-local-only
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
    volumes:
      - postgres_data:/var/lib/postgresql

What does the database service configure?

  • The pinned image runs PostgreSQL 18.6 in its own container.
  • The initialization values create the local linklab database and user.
  • The readiness check tests whether that database can accept connections.
  • The volume mount stores PostgreSQL data in the named volume.
  • Add this named volume declaration at the bottom of compose.yml by copying this code:
volumes:
  postgres_data:

Why declare the volume?

The top-level declaration creates postgres_data as a Compose-managed volume. The database service mounts that storage at PostgreSQL's data path.

The first database start can take a few minutes while Docker downloads the PostgreSQL image. A quiet pause during that download is expected.

  • Save compose.yml.
  • Create the database service and volume by running this command:
docker compose up --build -d

What changes in the stack?

Compose creates the database container and the named volume. The application replicas remain in memory mode until their environment settings change.

You should see Compose finish without reporting a failed database health check. The existing URL shortener should remain available through Caddy.

Does the database fail to start?

Check the indentation beneath db: and healthcheck:. Confirm that postgres_data has both a service mount and a top-level declaration.

Ask for help with the Compose database service.

Both application services now need identical database settings. Their instance names remain different so the page can still reveal which replica handled each request.

  • Find the existing app1 and app2 service blocks in compose.yml.
  • Replace both service blocks with this configuration:
  app1:
    build: .
    environment:
      INSTANCE_NAME: app1
      STORAGE_MODE: postgres
      DB_HOST: db
      DB_NAME: linklab
      DB_USER: linklab
      DB_PASSWORD: linklab-local-only
    depends_on:
      db:
        condition: service_healthy

  app2:
    build: .
    environment:
      INSTANCE_NAME: app2
      STORAGE_MODE: postgres
      DB_HOST: db
      DB_NAME: linklab
      DB_USER: linklab
      DB_PASSWORD: linklab-local-only
    depends_on:
      db:
        condition: service_healthy

How do the replicas share state?

  • Both replicas select postgres storage.
  • Both replicas connect to the db service through the private Compose network.
  • Both replicas wait for the database health check to pass.
  • The unique instance names keep load balancing visible.
  • Save compose.yml.
  • Recreate the complete stack by running this command:
docker compose up --build -d

What does this launch do?

Compose starts PostgreSQL first. It starts both application replicas after the database becomes healthy.

Caddy keeps routing browser requests between the two private application services.

  • Open http://localhost:8080 in your browser.

You should see the LinkLab form. The architecture line should identify one replica with postgres storage.

Does LinkLab fail to load?

Confirm that both replicas use DB_HOST: db. Check that the database values match between db and both app services.

Ask for help with the stateless Compose stack.

Prove that both replicas share the link

The decisive test sends one code through the load balancer. Repeated reads should succeed regardless of which replica receives the request.

  • Return to http://localhost:8080 in your browser.
  • Enter demo in the Short code field.
  • Enter the same destination URL you used in the previous step in the Destination URL field.
  • Click Create short link.
  • Click the generated /demo link to confirm that it redirects to your destination.

Before you check the debug route, do you expect found to change when the serving replica changes?

  • Enter http://localhost:8080/debug/demo in your browser address bar.
  • Refresh the debug route several times.

You should see instance alternate between app1 and app2. Every response should show found as true with storage set to postgres.

That is the state problem solved. Either replica can now answer from the same durable mapping.

Why SQL for LinkLab?

LinkLab stores each mapping as links(code PRIMARY KEY, target_url). The primary key enforces one row per short code.

A NoSQL key-value store could represent the same lookup as {code: target_url}. That shape suits a predictable key lookup at managed scale.

PostgreSQL fits this local product because its explicit constraint protects code uniqueness. Its atomic update also makes repeated code submissions deterministic.

Does found still change between true and false?

Confirm that both app services use STORAGE_MODE: postgres. Recreate the stack after saving compose.yml.

Create demo through the load-balanced page. A mapping created before the PostgreSQL switch existed only in memory.

Ask for help with inconsistent database-backed lookups.

✔️ Awesome, I've got everything!

Great work. Save app.py and compose.yml. Your stack should now contain PostgreSQL plus two stateless application replicas behind Caddy.

ⓧ I'd like to double check the full code

Compare your three project files with these cumulative versions. The Caddyfile remains unchanged in this step.

import os

import psycopg
from flask import Flask, redirect, render_template, request

app = Flask(__name__)

INSTANCE_NAME = os.getenv("INSTANCE_NAME", "local")
STORAGE_MODE = os.getenv("STORAGE_MODE", "memory")
MEMORY_LINKS = {}


def database_connection():
    return psycopg.connect(
        host=os.getenv("DB_HOST"),
        dbname=os.getenv("DB_NAME"),
        user=os.getenv("DB_USER"),
        password=os.getenv("DB_PASSWORD"),
    )


def initialize_database():
    if STORAGE_MODE != "postgres":
        return

    with database_connection() as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS links (
                code text PRIMARY KEY,
                target_url text NOT NULL
            )
            """
        )


def save_link(code, target_url):
    if STORAGE_MODE == "memory":
        MEMORY_LINKS[code] = target_url
        return

    with database_connection() as connection:
        connection.execute(
            """
            INSERT INTO links (code, target_url)
            VALUES (%s, %s)
            ON CONFLICT (code)
            DO UPDATE SET target_url = EXCLUDED.target_url
            """,
            (code, target_url),
        )


def load_link(code):
    if STORAGE_MODE == "memory":
        return MEMORY_LINKS.get(code)

    with database_connection() as connection:
        row = connection.execute(
            "SELECT target_url FROM links WHERE code = %s",
            (code,),
        ).fetchone()

    return row[0] if row else None


def render_home(short_path=None, error=None, code="", target_url=""):
    return render_template(
        "index.html",
        instance_name=INSTANCE_NAME,
        storage_mode=STORAGE_MODE,
        short_path=short_path,
        error=error,
        code=code,
        target_url=target_url,
    )


initialize_database()


@app.get("/")
def index():
    return render_home()


@app.post("/shorten")
def shorten():
    code = request.form["code"].strip()
    target_url = request.form["url"].strip()

    valid_code = 3 <= len(code) <= 32 and all(
        character.isascii()
        and (character.isalnum() or character == "-")
        for character in code
    )
    if not valid_code:
        return render_home(
            error="Use 3 to 32 ASCII letters, numbers, or hyphens for the code.",
            code=code,
            target_url=target_url,
        ), 400

    if not target_url.startswith(("http://", "https://")):
        return render_home(
            error="The destination must begin with http:// or https://.",
            code=code,
            target_url=target_url,
        ), 400

    save_link(code, target_url)
    return render_home(short_path=f"/{code}", code=code, target_url=target_url)


@app.get("/debug/<code>")
def debug_link(code):
    target_url = load_link(code)
    return {"instance": INSTANCE_NAME, "storage": STORAGE_MODE, "code": code, "found": target_url is not None}


@app.get("/health")
def health():
    return {"status": "ok", "instance": INSTANCE_NAME}


@app.get("/<code>")
def follow_link(code):
    target_url = load_link(code)
    if target_url is None:
        return "Short link not found.", 404
    return redirect(target_url)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)
services:
  db:
    image: postgres:18.6-alpine
    environment:
      POSTGRES_DB: linklab
      POSTGRES_USER: linklab
      POSTGRES_PASSWORD: linklab-local-only
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
    volumes:
      - postgres_data:/var/lib/postgresql

  app1:
    build: .
    environment:
      INSTANCE_NAME: app1
      STORAGE_MODE: postgres
      DB_HOST: db
      DB_NAME: linklab
      DB_USER: linklab
      DB_PASSWORD: linklab-local-only
    depends_on:
      db:
        condition: service_healthy

  app2:
    build: .
    environment:
      INSTANCE_NAME: app2
      STORAGE_MODE: postgres
      DB_HOST: db
      DB_NAME: linklab
      DB_USER: linklab
      DB_PASSWORD: linklab-local-only
    depends_on:
      db:
        condition: service_healthy

  caddy:
    image: caddy:2.11.7-alpine
    ports:
      - "8080:8080"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    depends_on:
      - app1
      - app2

volumes:
  postgres_data:
http://localhost:8080 {
    reverse_proxy app1:8000 app2:8000 {
        lb_policy round_robin
    }
}

Your replicas now scale without splitting their data. Next, you will move encryption to Caddy so LinkLab enters through trusted local HTTPS.

Upgrade to Trusted Local HTTPS

Your two replicas already share one PostgreSQL source of truth. However, browser traffic still reaches Caddy over HTTP.

Caddy terminates TLS at the load balancer. This puts certificate handling at the edge.

The browser uses HTTPS. The replicas keep private HTTP connections inside Docker Compose.

In this step, get ready to:
  • Configure Caddy's HTTPS entry point in Compose.
  • Trust Caddy's local root certificate in macOS.
  • Verify LinkLab's HTTP redirect and encrypted connection.
Configure Caddy for HTTPS

A bare localhost site activates Caddy's automatic local HTTPS. Caddy then redirects port 80 requests to port 443.

  • Switch back to Caddyfile in VS Code.
  • Select all of the existing configuration.
  • Replace the selected configuration with this secure site block:
localhost {
    reverse_proxy app1:8000 app2:8000 {
        lb_policy round_robin
    }
}

What does this configuration do?

  • The localhost site address enables automatic local HTTPS.
  • The reverse_proxy block forwards requests to the two private application services.
  • The lb_policy round_robin setting continues alternating requests between the replicas.
  • Save Caddyfile.
  • Confirm the first line now reads localhost {.

Still seeing the old HTTP configuration?

  • Confirm you edited the Caddyfile inside the linklab workspace.
  • Check that the first line no longer includes a port or an explicit HTTP scheme.

Help me check my Caddy site configuration.

Caddy stores its local certificate authority under /data. Named volumes preserve that state when Compose recreates the container.

  • Switch back to compose.yml in VS Code.
  • Find the caddy: service near the bottom of the file.
  • Replace the current Caddy service plus the existing volumes block with this configuration:
  caddy:
    image: caddy:2.11.7-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app1
      - app2

volumes:
  postgres_data:
  caddy_data:
  caddy_config:

How does Compose support HTTPS?

  • The 80:80 mapping accepts browser requests over HTTP.
  • The 443:443 mapping accepts encrypted browser requests over HTTPS.
  • The caddy_data volume preserves Caddy's local certificate authority.
  • The caddy_config volume preserves Caddy's runtime configuration state.
  • Save compose.yml.
  • Confirm the Caddy service now lists two port mappings plus three volume mounts.

Does the Compose file look invalid?

  • Check that caddy: remains indented beneath services:.
  • Check that the final volumes: key begins at the far left.
  • Confirm postgres_data remains in the final volumes block.

Help me fix the YAML structure in my Compose file.

Before you recreate the stack, do you expect LinkLab's four services to return in a running state?

  • Apply the new HTTPS configuration by running this command in the terminal from earlier:
docker compose up --build -d

What does this command do?

Compose builds the application image before starting the services in the background. The changed Caddy service is recreated with the standard HTTP and HTTPS ports.

You'll see all four services reach a running state. Caddy also generates its local certificate authority inside the persistent data volume.

Did the stack fail to start?

  • Confirm Docker Desktop is still running.
  • Check whether another local application already uses port 80 or port 443.
  • Review the indentation around the Caddy service in compose.yml.

Help me diagnose why the HTTPS Compose stack does not start.

✔️ Awesome, I've got everything!

Your Caddy and Compose files now define the complete local HTTPS edge. The running Caddy container has persistent storage for its local certificate authority.

ⓧ I'd like to double check the full code

  • Compare your saved Caddyfile with this complete version.
localhost {
    reverse_proxy app1:8000 app2:8000 {
        lb_policy round_robin
    }
}
  • Compare your saved compose.yml with this complete version.
services:
  db:
    image: postgres:18.6-alpine
    environment:
      POSTGRES_DB: linklab
      POSTGRES_USER: linklab
      POSTGRES_PASSWORD: linklab-local-only
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
    volumes:
      - postgres_data:/var/lib/postgresql

  app1:
    build: .
    environment:
      INSTANCE_NAME: app1
      STORAGE_MODE: postgres
      DB_HOST: db
      DB_NAME: linklab
      DB_USER: linklab
      DB_PASSWORD: linklab-local-only
    depends_on:
      db:
        condition: service_healthy

  app2:
    build: .
    environment:
      INSTANCE_NAME: app2
      STORAGE_MODE: postgres
      DB_HOST: db
      DB_NAME: linklab
      DB_USER: linklab
      DB_PASSWORD: linklab-local-only
    depends_on:
      db:
        condition: service_healthy

  caddy:
    image: caddy:2.11.7-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app1
      - app2

volumes:
  postgres_data:
  caddy_data:
  caddy_config:
Trust Caddy's local certificate

Caddy has generated a local root certificate that can verify certificates for localhost. Your Mac must trust that root before the browser accepts the encrypted connection.

Trusting a root certificate changes the macOS System keychain. Here, the change only trusts the local authority generated by your Caddy container.

macOS may ask for your administrator password when the trust command runs. This authorizes the System keychain change.

  • Copy Caddy's local root certificate and trust it by running the documented macOS command:
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/root.crt && sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/root.crt

What does this command do?

The first command copies root.crt from the Caddy container to /tmp/root.crt on your Mac.

The second command adds that certificate as a trusted root in /Library/Keychains/System.keychain.

  • Enter your Mac administrator password if macOS requests it.
  • Confirm the terminal returns to the prompt without an error.

A quiet return to the terminal prompt counts as success. The browser provides the visible trust check next.

Did the certificate trust command fail?

  • Confirm the Caddy service is running before copying the certificate.
  • Check that you entered the password for an administrator account on your Mac.
  • Confirm the command copied the certificate to /tmp/root.crt.

Help me troubleshoot the local Caddy certificate trust command.

Verify the secure path

TLS termination keeps certificate handling at the load balancer. Caddy decrypts each browser request before forwarding it to a private application replica over HTTP.

Before you check, what do you expect Caddy to do when the browser enters through HTTP?

  • Enter http://localhost in your browser's address bar.

You'll arrive at https://localhost. The page loads without a certificate warning.

Still seeing a certificate warning?

  • Rerun the certificate trust command from the previous substep.
  • Close the browser after trusting the certificate.
  • Reopen the browser before checking https://localhost again.

Help me fix the local HTTPS certificate warning.

  • Enter secure-demo in the Short code field.
  • Enter https://localhost/debug/demo in the Destination URL field.
  • Click Create short link.

You'll see the new short path /secure-demo on the page.

  • Click the /secure-demo link.

You'll see a JSON response from one replica. It shows found as true plus storage as postgres.

Before you refresh the debug route, which replica do you expect to answer each request?

  • Return to https://localhost/debug/demo in the browser's address bar.
  • Refresh the page several times.

You'll see app1 and app2 alternate. Every response keeps found as true with storage set to postgres.

Are the replicas failing to alternate?

  • Confirm both app1 and app2 are running.
  • Check that Caddyfile still contains both private upstreams.
  • Refresh the debug route instead of the short-link destination page.

Help me troubleshoot Caddy's round-robin responses over HTTPS.

You now have a trusted local HTTPS entry point for LinkLab. Caddy secures browser traffic while both stateless replicas continue serving shared PostgreSQL data.

Secret mission

Survive a Replica Failure

A second replica only matters when traffic can avoid a failed one. Add active health checks to Caddy. Stop app1 to prove app2 keeps LinkLab available over HTTPS.

Clean Up Your Resources

Clean Up Your Resources

LinkLab runs locally through Docker Desktop. There are no ongoing cloud costs.

Choose whether to keep the lab running, pause its services for later, or remove its local runtime resources.

Resources you used:

  • The db, app1, app2, and caddy service containers.
  • The private default Docker Compose network.
  • The postgres_data, caddy_data, and caddy_config named volumes.
  • The trusted local root certificate created by Caddy in the macOS System keychain.

Keep everything running

No action needed. Choose this if you are still testing LinkLab's load balancing or replica failure handling.

  • Keep Docker Desktop running.
  • Return to https://localhost whenever you want to use LinkLab.
  • Keep the trusted local root certificate installed so your browser continues to trust LinkLab's local HTTPS connection.

Pause - I'll come back to this later

Stop the services to free local memory. Your containers plus saved LinkLab mappings remain available for your next session.

  • Switch back to the VS Code terminal for your LinkLab workspace.
  • Pause all four services by running this command:
docker compose stop

What Does This Command Do?

This stops the LinkLab services without removing their containers. Your short-link mappings remain inside the PostgreSQL named volume.

  • Confirm in Docker Desktop that the four LinkLab services show a stopped state.

Your lab is paused. The database mapping you created remains available.

  • Resume the existing services when you return by running this command:
docker compose start

What Happens When You Resume?

This starts the existing service containers. It preserves the data held in your named volumes.

  • Open https://localhost after the services start.

You'll see LinkLab running with the short-link mappings you saved earlier.

Stack Command Not Working?

Confirm that your terminal is inside the LinkLab workspace containing compose.yml. Confirm that Docker Desktop is running.

Help me troubleshoot my LinkLab Compose command.

Delete - I don't want to use this again

Removing the stack is a full reset. It deletes every short-link mapping stored in the PostgreSQL volume.

  • Switch back to the VS Code terminal for your LinkLab workspace.
  • Remove the containers, private network, and named volumes by running this command:
docker compose down --volumes

What Does This Command Remove?

  • Compose removes the four service containers.
  • Compose removes the private default network.
  • The --volumes option removes postgres_data, caddy_data, and caddy_config.
  • Confirm in Docker Desktop that the LinkLab service containers are gone.
  • Try to open https://localhost in your browser.

You can no longer reach LinkLab because the Caddy container has been removed.

Cleanup Command Failed?

Confirm that your terminal is inside the LinkLab workspace containing compose.yml. Docker Desktop must remain running while Compose removes the resources.

Help me troubleshoot the LinkLab cleanup command.

The trusted certificate remains in the macOS System keychain after Docker removes the stack. Removing it completes the local trust cleanup.

  • Press Cmd+Space to open macOS search.
  • Type Keychain Access and press Enter.
  • Select the System keychain.
  • Select the Certificates category.
  • Find Caddy Local Authority.
  • Select the Caddy Local Authority certificate.
  • Choose the certificate's Delete action.
  • Confirm the deletion when macOS asks.

You will no longer see Caddy Local Authority in the System certificate list. Your Mac no longer trusts that local Caddy certificate.

Nice Work!

Nice Work!

You made it! You built LinkLab into a resilient URL shortener. Caddy now terminates trusted local HTTPS for two application replicas backed by shared PostgreSQL data.

You've learned how to:

  • Build a working URL shortener with Flask inside a Docker container. Create memorable paths that redirect to their stored destinations.
  • Scale the app to two interchangeable replicas behind Caddy. Use round-robin load balancing to reveal the failure caused by in-memory state.
  • Replace local mappings with shared PostgreSQL persistence so either replica can answer a request. Compare the SQL row model with a NoSQL key-value shape. Terminate TLS at Caddy so HTTP redirects to trusted HTTPS.
  • Secret Mission: Add active health checks plus retry behavior so app2 keeps serving traffic during a controlled app1 outage.

Ready to quiz yourself?