57.1 Project 1: Launch a website with a container (nginx + pandoc build)
Why: the problem
Suppose we work at a small travel startup in Pune, like a mini MakeMyTrip. The marketing team wants a simple company website live today. The old way is to log in to an EC2 server, install nginx by hand, copy files with scp and edit configs on the server. Next month a new developer asks: which nginx version is it? Which config? Nobody knows. If the server dies, we must repeat everything from memory. With Docker, the website and the web server become one image. The same image runs on a laptop, on EC2 and on any cloud. To update the site we build a new image with a new tag. To roll back we run the old tag again, in seconds. In this project we write the pages in Markdown, convert them to HTML inside a build stage, and serve them with nginx. We also add our own nginx config, a health check, gzip, caching headers and a custom 404 page. This is the first real project, so we go slowly and check the output of every command.
What: every term in plain English
- Static website — Pages that are plain files: HTML, CSS and images. No database and no server code.
- nginx — A fast web server. It reads files from a folder and sends them to the browser.
- pandoc — A tool that converts Markdown text into HTML pages. We run it inside a build stage.
- Multi-stage build — A Dockerfile with two FROM lines. Stage 1 builds the pages, stage 2 only serves them.
- Image tag — A version label on an image, like site:1.0 and site:1.1. We roll back by running an old tag.
- Port mapping — -p 8080:80 means: traffic to port 8080 on our machine goes to port 80 in the container.
- HEALTHCHECK — A command Docker runs every few seconds to see if the site still answers.
- Document root — The folder nginx serves. In the nginx image it is /usr/share/nginx/html.
How: step by step
- Write the pages in Markdown. index.md, about.md and 404.md in a content folder.
- Write the nginx config. site.conf with gzip, cache headers and the 404 page.
- Write a two-stage Dockerfile. pandoc converts Markdown to HTML; nginx serves it.
- Build the image with a tag. docker build -t travelsite:1.0 .
- Run the container. docker run -d -p 8080:80 --name web travelsite:1.0
- Test it. curl, the browser and docker inspect for health.
- Update, roll back and clean up. Build 1.1, switch, switch back, then remove everything.
Architecture
Folder layout
travelsite/
content/
index.md
about.md
404.md
site.conf
Dockerfile
.dockerignore
compose.yaml
Example 1: Build the pages and the image (version 1
Goal. Build the pages and the image (version 1.0).
content/index.md
---
title: Sahyadri Trips
---
# Welcome to Sahyadri Trips
Weekend treks from Pune and Mumbai.
## Popular trips
- Rajgad fort trek, every Saturday
- Harihar fort, monsoon special
- Kalsubai peak sunrise trek
## Contact
Call us on +91 00000 00000 or write to hello@example.com.
content/about.md
---
title: About us
---
# About Sahyadri Trips
We are a small team of trek leaders.
We started in 2019 with one bus and twenty trekkers.
content/404.md
---
title: Page not found
---
# Oops, this page is not here
Go back to the [home page](/).
site.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript image/svg+xml;
location / {
try_files $uri $uri.html $uri/ =404;
}
location ~* \.(css|png|jpg|svg)$ {
expires 7d;
add_header Cache-Control "public";
}
location = /healthz {
access_log off;
return 200 "ok";
}
error_page 404 /404.html;
}
Dockerfile
# Stage 1: convert Markdown pages to HTML
FROM pandoc/core:3.5 AS build
WORKDIR /src
COPY content/ ./content/
RUN mkdir -p /out && for f in content/*.md; do \
name=$(basename "$f" .md); \
pandoc "$f" --standalone --output "/out/$name.html"; \
done
# Stage 2: serve the HTML with nginx
FROM nginx:1.27-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY site.conf /etc/nginx/conf.d/site.conf
COPY --from=build /out/ /usr/share/nginx/html/
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost/healthz || exit 1
.dockerignore
.git
*.log
notes/
Commands
docker build -t travelsite:1.0 .
docker images travelsite
What each line does
FROM pandoc/core:3.5 AS build— The build stage. It has pandoc installed and nothing else.for f in content/*.md— Loops over every Markdown page.pandoc ... --standalone— Makes a full HTML page with head and body from Markdown.FROM nginx:1.27-alpine— The final stage. A small nginx image, about 20 MB.rm default.conf— Removes the sample config so only our config is used.COPY --from=build— Takes only the HTML files from stage 1. pandoc does not ship.try_files $uri $uri.html— Lets /about open about.html without typing .html.location = /healthz— A tiny URL used only by the health check.HEALTHCHECK ... wget— Docker marks the container healthy or unhealthy.
Green output
[+] Building 9.8s (12/12) FINISHED
=> [build 4/4] RUN mkdir -p /out && for f in content/*.md; do ...
=> [stage-1 3/4] COPY site.conf /etc/nginx/conf.d/site.conf
=> [stage-1 4/4] COPY --from=build /out/ /usr/share/nginx/html/
=> naming to docker.io/library/travelsite:1.0
REPOSITORY TAG IMAGE ID CREATED SIZE
travelsite 1.0 5c1d9e2a7b31 10 seconds ago 48.2MB
Expected result. We have one small image. It holds three HTML pages and our nginx config. pandoc is not inside it, so the image stays small.
Example 2: Run the website and test it like a user and like an admin
Goal. Run the website and test it like a user and like an admin.
Commands
docker run -d --name web -p 8080:80 --restart unless-stopped travelsite:1.0
docker ps --filter name=web
curl -s http://localhost:8080/ | grep -o "Welcome to Sahyadri Trips"
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/about
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/missing-page
curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/ | grep -i content-encoding
docker inspect -f '{{.State.Health.Status}}' web
docker logs --tail 5 web
What each line does
--restart unless-stopped— Starts the site again after a server reboot.grep -o— Prints only the matching text, so we know the page is right.-w %{http_code}— Prints only the HTTP status code./missing-page— Should give 404 and our own 404 page.Accept-Encoding: gzip— Checks that gzip is on.State.Health.Status— starting, then healthy after the first check passes.
Green output
CONTAINER ID IMAGE STATUS PORTS NAMES
a91f0c2d3e4b travelsite:1.0 Up 20 seconds (healthy) 0.0.0.0:8080->80/tcp web
Welcome to Sahyadri Trips
200
404
Content-Encoding: gzip
healthy
172.17.0.1 - - [25/Oct/2026:10:12:01 +0000] "GET /about HTTP/1.1" 200 812 "-" "curl/8.5.0"
Expected result. Open http://localhost:8080 in the browser (or http://EC2-PUBLIC-IP:8080 after opening the port in the security group). The home page, the about page and the 404 page all work.
Example 3: Ship version 1
Goal. Ship version 1.1, then roll back to 1.0 in seconds.
content/index.md (one new line)
## New this month
- Diwali special: Raigad fort night trek
Commands
docker build -t travelsite:1.1 .
docker rm -f web
docker run -d --name web -p 8080:80 --restart unless-stopped travelsite:1.1
curl -s http://localhost:8080/ | grep -o "Diwali special"
# something is wrong with 1.1? roll back:
docker rm -f web
docker run -d --name web -p 8080:80 --restart unless-stopped travelsite:1.0
curl -s http://localhost:8080/ | grep -c "Diwali special"
What each line does
build -t travelsite:1.1— A new image. The old 1.0 image is still on disk.rm -f web— Stops and removes the old container. The image stays.run ... :1.1— Starts the new version on the same port.run ... :1.0— Rollback is just running the old tag again.grep -c— Counts matches. 0 means the new line is gone, so rollback worked.
Green output
Diwali special
...
0
Expected result. Update and rollback each take a few seconds. There is no editing on the server, and every version is an image we can run again.
Example 4: Run the same site with Docker Compose
Goal. Run the same site with Docker Compose.
compose.yaml
services:
web:
build: .
image: travelsite:1.1
ports:
- "8080:80"
restart: unless-stopped
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
Commands
docker compose up -d --build
docker compose ps
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/
docker compose logs --tail 3 web
What each line does
build: .— Compose builds the Dockerfile in this folder.image: travelsite:1.1— The tag Compose gives the image it builds.read_only: true— The container cannot change its own files. Safer.tmpfs— nginx still needs two writable folders. tmpfs gives them in memory.up -d --build— Builds and starts in the background.
Green output
NAME IMAGE SERVICE STATUS PORTS
travel-web-1 travelsite:1.1 web Up 6 seconds (healthy) 0.0.0.0:8080->80/tcp
200
Expected result. One command starts the whole site. Next projects add a database next to this web service.
Every command, step by step (plain docker and Compose)
-
Make the folder and files
mkdir -p travelsite/content && cd travelsite lsWhat it does: Creates the project folder. We then create index.md, about.md, 404.md, site.conf and Dockerfile with an editor like nano or VS Code.
content -
Check the Docker engine
docker version --format '{{.Server.Version}}'What it does: Makes sure Docker is running before we build.
27.3.1 -
Build version 1.0
docker build -t travelsite:1.0 .What it does: Runs both stages and tags the final image.
naming to docker.io/library/travelsite:1.0 -
See the layers and size
docker history travelsite:1.0 --format '{{.Size}} {{.CreatedBy}}' | head -5What it does: Shows our COPY layers are only a few KB.
8.19kB COPY /out/ /usr/share/nginx/html/ # buildkit 4.10kB COPY site.conf /etc/nginx/conf.d/site.conf -
Look inside the image
docker run --rm travelsite:1.0 ls /usr/share/nginx/htmlWhat it does: Lists the HTML files that pandoc made.
404.html about.html index.html -
Test the nginx config
docker run --rm travelsite:1.0 nginx -tWhat it does: nginx checks the config syntax.
nginx: configuration file /etc/nginx/nginx.conf test is successful -
Run the site
docker run -d --name web -p 8080:80 travelsite:1.0What it does: Starts the container in the background.
a91f0c2d3e4b5f... -
Wait for healthy
docker ps --filter name=web --format '{{.Status}}'What it does: After about 15 seconds the status shows healthy.
Up 18 seconds (healthy) -
Test from the server
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/What it does: 200 means the site is up.
200 -
Follow the access log
docker logs -f webWhat it does: Open the site in the browser and watch requests arrive. Press Ctrl+C to stop following.
GET / HTTP/1.1" 200 -
Open the port on EC2 (if on AWS)
aws ec2 authorize-security-group-ingress --group-id sg-0123456789abcdef0 --protocol tcp --port 8080 --cidr 0.0.0.0/0What it does: Lets the internet reach port 8080. On a laptop skip this step.
"Return": true -
Clean up
docker rm -f web docker compose down docker image rm travelsite:1.0 travelsite:1.1 docker image prune -fWhat it does: Removes containers, images and dangling build layers.
Untagged: travelsite:1.0 Untagged: travelsite:1.1 Total reclaimed space: 48.2MB
When it is red 1
docker: Error response from daemon: driver failed programming external connectivity on endpoint web: Bind for 0.0.0.0:8080 failed: port is already allocated.
Cause. Another container or program already uses port 8080.
Fix, step by step
- Run docker ps and look for 0.0.0.0:8080 in the PORTS column.
- Stop that container with docker rm -f NAME, or use another port like -p 8081:80.
- On Linux check other programs with sudo ss -ltnp | grep 8080.
When it is red 2
nginx: [emerg] unknown directive "gzip_typess" in /etc/nginx/conf.d/site.conf:7
Cause. A typing mistake in site.conf. The container starts and exits at once.
Fix, step by step
- Run docker logs web to see this message.
- Fix the word in site.conf.
- Test before running: docker run --rm travelsite:1.0 nginx -t.
- Build again and run again.
When it is red 3
curl: (7) Failed to connect to 13.233.10.20 port 8080 after 21003 ms: Couldn't connect to server
Cause. Works on the server with localhost but not from the laptop. The EC2 security group blocks the port.
Fix, step by step
- Check curl localhost:8080 on the server first. If it works, Docker is fine.
- Add an inbound rule for TCP 8080 in the security group.
- Check that the instance has a public IP and you use http, not https.
When it is red 4
pandoc: content/index.md: openBinaryFile: does not exist (No such file or directory)
Cause. The content folder was not copied, often because .dockerignore lists it or the folder name is wrong.
Fix, step by step
- Run ls content in the project folder.
- Check .dockerignore does not contain content.
- Build again with docker build --no-cache -t travelsite:1.0 .
Practice tasks
Easy Task 1: Build and run
Why: Start.
What to do: Build travelsite:1.0 and open it on port 8080.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
docker build -t travelsite:1.0 .
docker run -d --name web -p 8080:80 travelsite:1.0
curl -s localhost:8080 | grep WelcomeEasy Task 2: Second port
Why: Two copies.
What to do: Run a second copy of the same image on port 8081.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
docker run -d --name web2 -p 8081:80 travelsite:1.0
curl -s -o /dev/null -w '%{http_code}' localhost:8081Easy Task 3: Look inside
Why: Know the image.
What to do: List the files nginx serves.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
docker exec web ls -l /usr/share/nginx/htmlMedium Task 4: Contact page
Why: Grow the site.
What to do: Add contact.md, build 1.2 and open /contact.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
docker build -t travelsite:1.2 .
docker rm -f web
docker run -d --name web -p 8080:80 travelsite:1.2
curl -s localhost:8080/contact | grep -i contactMedium Task 5: Rollback
Why: Safety.
What to do: Roll back from 1.2 to 1.1 and prove it.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
docker rm -f web
docker run -d --name web -p 8080:80 travelsite:1.1
curl -s -o /dev/null -w '%{http_code}' localhost:8080/contactMedium Task 6: Health
Why: Monitoring.
What to do: Break the health check on purpose and watch it turn unhealthy.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
docker exec web rm /etc/nginx/conf.d/site.conf
docker exec web nginx -s reload
sleep 50
docker inspect -f '{{.State.Health.Status}}' webMedium Task 7: Cache header
Why: Speed.
What to do: Add style.css and check the Expires header.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
curl -sI localhost:8080/style.css | grep -i -E 'expires|cache-control'Hard Task 8: Read-only
Why: Security.
What to do: Run with --read-only and the two tmpfs folders.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
docker run -d --name ro -p 8082:80 --read-only --tmpfs /var/cache/nginx --tmpfs /var/run travelsite:1.1Hard Task 9: Size check
Why: Lean images.
What to do: Compare the size of pandoc/core and travelsite.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -E 'pandoc|travelsite'Easy Task 10: Clean up
Why: Habit.
What to do: Remove all containers and images from this project.
- Use any Ubuntu 22.04/24.04 VM or a laptop with Docker Engine or Docker Desktop.
- Make a new empty folder for the project.
- Type every file yourself, then compare with the solution.
Solution
docker rm -f web web2 ro
docker image rm travelsite:1.0 travelsite:1.1 travelsite:1.2Homework: try at home
Add a contact page (contact.md) and a CSS file served with the 7-day cache header. Build 1.2, check the header with curl -I, then roll back to 1.1.
Recap
- A static website becomes one nginx image.
- Build the pages in one stage and serve them in another.
- Test the config with nginx -t before running.
- Each release is a tag; rollback is running the old tag.
- Open the port in the EC2 security group for outside access.
- Clean up containers and images after the project.
FAQ
Q. Do I need to know HTML?
No. We write Markdown and pandoc makes the HTML. Later you can add your own HTML and CSS files the same way.
Q. Can I use port 80 instead of 8080?
Yes: -p 80:80. On Linux port 80 is free only if no other web server runs on the host.
Q. Where do the files live after I remove the container?
Inside the image. The container is only a running copy, so removing it loses nothing.
Q. How do I add a domain name?
Point an A record (Route 53 or GoDaddy) to the server IP, and use port 80. Project 6 adds HTTPS.
Q. Why alpine?
The nginx alpine image is small and starts fast. It is fine for static sites.
Q. Should I edit files inside the running container?
No. Changes are lost when the container is replaced. Change the source, build a new tag, run it.
Interview questions
-
How would you host a static website with Docker?
Model answer: Multi-stage build: build the pages in stage 1, copy only the output into an nginx image, add a config and health check, and run with -p 80:80 and a restart policy.
-
How do you roll back a containerised website?
Model answer: Keep versioned tags and run the previous tag again; never use only latest.
-
What is the difference between EXPOSE and -p?
Model answer: EXPOSE is documentation inside the image; -p actually publishes the port on the host.
-
How do you check a container is healthy?
Model answer: A HEALTHCHECK in the Dockerfile, then docker ps or docker inspect -f '{{.State.Health.Status}}'.
-
Why make the container read-only?
Model answer: An attacker cannot change site files or drop tools; tmpfs gives only the folders nginx must write.
Quick quiz
Q1. Why do we use two stages in this Dockerfile?
Stage 1 has pandoc to build the pages. Stage 2 only has nginx and the HTML, so the final image is small and has fewer tools for attackers.
Q2. How do you roll back the website?
Remove the running container and run the older image tag again, for example travelsite:1.0.
Q3. What does -p 8080:80 mean?
Port 8080 on the host forwards to port 80 inside the container.
Q4. How do you check the nginx config inside the image?
docker run --rm travelsite:1.0 nginx -t
Q5. Why does the site work on localhost but not from the internet on EC2?
The security group does not allow the port. Add an inbound rule.
Samajla ka?
Revision card
Interview card