Lesson 07 VM Deploy with docker-compose
Theme:
Lesson 07 · Slide 1

Lesson 07 — VM Deploy with docker-compose

From CI green check to a real URL on a real VM, in one afternoon.

Cloud & DevOps Module 3

Lesson 07 · Slide 2

Recap & this module’s pivot

Modules 1–2

Took us from a Python service to a tested, scanned image pushed to GHCR on every merge to main.

  • Code lives in a container
  • CI builds, scans, signs, and pushes automatically
  • The image is available — but not running anywhere a user can hit it

Module 3

Finally puts that image somewhere a user can hit it — first on one VM you click into existence yourself (this lesson), then on one you provision with code (Lesson 08).

“The single-server stage is the shortest path to a real URL — and the longest you should stay there.”

Lesson 07 · Slide 3

The single-server architecture

  User → :443 (Caddy, TLS) → :8000 (api) → :5432 (postgres, same host)
                                               → :6379 (redis, same host)
                                   worker → :5432, :6379
  

One VM, four containers, one reverse proxy, one TLS cert. Everything described in docker-compose.prod.yml.

Lesson 07 · Slide 4

Why t3.micro (or equivalent)

Lesson 07 · Slide 5

The two compose files

docker-compose.yml (dev)

From Module 1 — exists already.

  • Builds the API + worker locally
  • Mounts code for live reload (volumes: ./api:/app)
  • Exposes ports on localhost

docker-compose.prod.yml (new)

New this lesson.

  • Pulls ghcr.io/<owner>/url-shortener-api:latest
  • No build context, no code mounts
  • Only Caddy config + named volumes for data
  • Ports only on Caddy (80:80, 443:443)

In production we use -f docker-compose.prod.yml alone to prevent the dev-shape from leaking in.

Lesson 07 · Slide 6

Pinning by tag vs. digest

services:
  api:
    image: ghcr.io/${GHCR_OWNER}/url-shortener-api:latest   # mutable tag
    # image: ghcr.io/${GHCR_OWNER}/url-shortener-api@sha256:abc...  # immutable digest

:latest is fine for the lab — docker compose pull always fetches the freshest image, which is exactly what we want during development.

For real production: switch to a digest pin or a versioned tag and let CI bump it. This course ships :latest in Module 3 and replaces it with digests in Module 5 when ArgoCD’s image-updater takes over the tag-bump loop.

Lesson 07 · Slide 7

Caddy as the reverse proxy

Caddy is one Go binary that fronts the API, terminates TLS, and obtains the cert from Let’s Encrypt automatically — no separate certbot, no NGINX config dance.

The entire production config is one block:

{$DOMAIN} {
    encode zstd gzip
    reverse_proxy api:8000
}

{$DOMAIN} is read from the environment, so the same Caddyfile works for a real domain (DOMAIN=urls.example.com) and for the developer’s tls internal fallback (DOMAIN=localhost).

Lesson 07 · Slide 8

TLS in 30 seconds

You should see this line in docker compose logs caddy:

certificate obtained successfully

No DNS? Set DOMAIN=localhost — Caddy issues a locally-trusted internal cert instead.

Lesson 07 · Slide 9

Secrets and .env on the VM

The production .env lives on the VM at /srv/url-shortener/.env, owned by root, mode 0600, and is never committed to git. The compose file references it with env_file:. The Module-2 CI workflow does not touch this file; it only pushes images.

Five variables the URL shortener needs at this stage:

Module 4 moves secrets to AWS SSM / Secrets Manager. Until then, /srv/.env is the source of truth.

Lesson 07 · Slide 10

The deploy command

ssh ubuntu@$VM_IP
cd /srv/url-shortener
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml logs -f --tail=50 api

That’s the entire deploy. Three commands. Re-deploying on a new image is the same three commands.

Lesson 07 · Slide 11

Health-check, rollback, and the limits of single-VM

Verify the deploy:

curl -fsS https://urls.example.com/healthz

200 means you shipped. If it fails:

docker compose ps
docker compose logs api
docker compose down && docker compose up -d

Rollback: pin a known-good tag in .env (API_TAG=v1.2.0), then pull && up -d. Same three commands.

There is no zero-downtime swap on a single VM. up -d stops the old container, then starts the new one — a brief gap. Module 4’s Kubernetes rolling update eliminates that gap.

Lesson 07 · Slide 12

What’s good — and what’s bad — about this shape

Good

  • The shortest deploy in this course
  • Real TLS, real DNS, real cost (~$8/mo or free-tier $0)
  • A working URL you can show off today
  • Three-command deploy loop easy to memorise

Bad

  • The VM is a pet — one bad apt upgrade and the only copy is gone
  • State (Postgres) lives on the same disk as the runtime
  • Scaling = buy a bigger VM
  • No zero-downtime deploys

This is why Lesson 08 puts the VM under Terraform and Module 4 graduates the whole stack to Kubernetes.

Lesson 07 · Slide 13

What we’ll build today

Lesson 07 · Slide 14

Recap & what’s next

Coming up: Lesson 08 — replace the by-hand VM with a Terraform module so the same VM can be torn down and re-created from code, and so you can hand the README to a teammate.
1 / ?