Ten tools, each doing one job. Pick any of them to see why it
is here and exactly how it is used in this project.
Packaging
Docker
The site has to run identically on a laptop and on a server. Docker
freezes nginx, the config and the HTML into one image, so what is tested is
byte-for-byte what is deployed.
- Built from
nginx:1.27-alpine — a 21 MB final image
- Built
--platform linux/amd64, because the EC2 host is x86_64 while the
development Mac is arm64 — a native build would refuse to start on the server
- Ships a
HEALTHCHECK hitting /healthz, so Docker itself
knows whether the site is actually serving
Orchestration
Docker Compose
One command has to bring up the whole stack — web server, auto-updater and
TLS proxy — and behave differently on a laptop than in production.
- Three layered files: a base, a dev override that builds locally and
bind-mounts the site for instant edits, and a prod overlay that pulls
the published image
- Production adds log rotation and a memory limit so a runaway container cannot fill
the disk or starve the box
- Ports are declared once in the base file — Compose appends list entries when
merging, so redeclaring them would publish two ports at once
Infrastructure as code
Terraform
Clicking through the AWS console is not repeatable. Terraform declares the
entire environment as code, so it can be destroyed and rebuilt identically.
- Creates a dedicated VPC, public subnet, internet gateway and route table — no reliance
on a default VPC, which not every account has
- Security group opens 80 and 443 to the world, 22 for admin, and 8080 only to five
specific ranges
- An Elastic IP keeps the address stable across restarts
user_data installs Docker at first boot, and Terraform writes the Ansible
inventory automatically so the next step needs no manual setup
Configuration management
Ansible
Terraform creates the server; Ansible decides what runs on it. Being
idempotent, it can be run any number of times and only changes what has drifted.
- Two roles: one installs Docker and the Compose plugin, the other pulls the image and
brings the stack up
- Renders the Compose file and the TLS config from templates, so the server holds no
hand-edited configuration
- Finishes by polling
/healthz — a green run means the site is genuinely
serving, not merely that the commands exited zero
- Verified idempotent: the second run reports
changed=0
Continuous integration
Jenkins
Building and pushing by hand is slow and easy to get wrong. Jenkins does it
the same way every time, and refuses to publish anything that fails its checks.
- Declarative pipeline: checkout → lint → build image → smoke test → push
- The smoke test runs the freshly built image and waits on its healthcheck, so a
broken image can never reach Docker Hub
- Publishes three tags — an immutable
main-<sha>, a moving
latest, and build-<n> to trace an image back to a run
- Publishing is gated on
main: branches build and test but never release
Trigger
GitHub Webhook
Polling for changes wastes cycles and adds delay. A webhook makes GitHub tell
Jenkins the instant something lands.
- A push to
main posts to Jenkins, which starts the build within seconds
- Port 8080 is open only to the operator's address plus GitHub's four published hook
ranges — a public Jenkins is a well-known target
- A declarative trigger only registers after the job has run once, which is the
usual reason a webhook appears to do nothing
Continuous deployment
Watchtower
Jenkins publishes the image, but something has to put it on the server.
Watchtower closes that last gap without any deploy key or inbound access.
- Polls Docker Hub every five minutes and replaces the container when the digest
behind
latest changes
- Runs with
--label-enable, so it only touches containers explicitly opted
in — nothing else on the host is at risk
--cleanup removes the superseded image so the disk does not creep
- Because it follows a digest, pinning an immutable tag would disable auto-updates
entirely
Web server
nginx
The thing actually serving this page. Small, fast, and boring in the way
infrastructure should be.
- Serves the static files with gzip enabled
- Exposes a
/healthz endpoint returning ok — used by the
container healthcheck, by Ansible, and by the CI smoke test
- Now bound to the loopback only, reachable exclusively through the TLS proxy
TLS
Caddy
Phones refuse plain HTTP more aggressively every year. Caddy obtains and
renews a real certificate on its own, with no cron job and no manual steps.
- Fetches a Let's Encrypt certificate automatically and renews it well before expiry
- Redirects HTTP to HTTPS and adds HSTS and content-type protections
- Keeps a raw-IP fallback listener, so the site stays reachable even if certificate
issuance ever fails — a bad certificate cannot take the site down
Hosting
AWS EC2
Everything above has to run somewhere with a public address. One small
instance carries the whole system.
- A single
t3.micro running Amazon Linux 2023 in ap-south-2
- Hosts the site, the auto-updater, the TLS proxy and the Jenkins controller —
with a swapfile added so builds do not exhaust its 1 GB of memory
- Managed through SSM over port 443, so no inbound SSH is required at all
- Root volume encrypted; IMDSv2 required