From CI green check to a real URL on a real VM, in one afternoon.
Cloud & DevOps Module 3
Took us from a Python service to a tested, scanned image pushed to GHCR on every merge to main.
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.”
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.
t3.micro (or equivalent)e2-micro (always-free in some regions)Standard_B1ss-1vcpu-1gb (~$6/mo)docker-compose.yml (dev)From Module 1 — exists already.
volumes: ./api:/app)localhostdocker-compose.prod.yml (new)New this lesson.
ghcr.io/<owner>/url-shortener-api:latest80:80, 443:443)In production we use -f docker-compose.prod.yml alone to prevent the dev-shape from leaking in.
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.
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).
DOMAIN=urls.example.com in .envdocker compose up -dcaddy_data volumeYou 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.
.env on the VMThe 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:
GHCR_OWNER — owner segment of the image namesDOMAIN — public hostname (or localhost)DB_PASSWORD — Postgres passwordSECRET_KEY — Flask/FastAPI session secretWORKER_INTERVAL — polling interval (non-secret but lives here too)Module 4 moves secrets to AWS SSM / Secrets Manager. Until then, /srv/.env is the source of truth.
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.
docker compose (space, v2 plugin) — not docker-compose (hyphen, legacy binary)up -d recreates only changed services; volumes and networks persistup -d to confirm no crash-loopsVerify 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.
apt upgrade and the only copy is goneThis is why Lesson 08 puts the VM under Terraform and Module 4 graduates the whole stack to Kubernetes.
docker compose plugindocker-compose.prod.yml + caddy/Caddyfile (committed under sample-app/) to /srv/url-shortener/.env with GHCR_OWNER, DOMAIN, DB_PASSWORD, SECRET_KEY, WORKER_INTERVALlocalhost fallback available)docker compose pull && docker compose up -d