LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
DockerBeginner~1.5 hours

Production-Ready Static Website with Nginx

Package a portfolio website into a small Nginx image with clean URLs, a custom 404 page, compression, browser caching, a health check, and painless updates and rollbacks.

DockerfileNginxHEALTHCHECKImage Versioning

Overview

Static websites — portfolios, documentation, landing pages, built React or Vue apps — are the simplest thing to containerise, and a great place to learn production habits. In this project you will build a self-contained Nginx image for Priya’s portfolio that is fast, cache-friendly and easy to update and roll back.

What You'll Build
  • A portfolio site with clean URLs (/about instead of /about.html) and a custom 404 page.
  • An Nginx configuration with gzip compression and browser caching for stylesheets.
  • A versioned image with a HEALTHCHECK, so Docker knows whether the site is working.
  • A release routine: build a new version, swap the container, and roll back in seconds.

Prerequisites

  • Running containers and publishing ports — lessons 3 and 7.
  • Writing a Dockerfile — lesson 5.
  • Basic HTML. (Nginx basics help; the Linux course’s Nginx project covers them.)

Step 1: The Website

Create a folder called portfolio-site with this layout. The website lives in site/; the Nginx configuration and Dockerfile sit next to it:

tree -a
Output

Click Run to see what this code prints.

site/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Priya Sharma - Developer</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<h1>Hi, I'm Priya</h1>
<p>I build web apps and ship them in containers.</p>
<a href="/about">About me</a>
</body>
</html>
site/about.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>About - Priya Sharma</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<h1>About me</h1>
<p>Python, Docker and Linux enthusiast.</p>
<a href="/">Home</a>
</body>
</html>
site/404.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Page not found</title></head>
<body><h1>404 - This page does not exist</h1><a href="/">Go home</a></body>
</html>
site/css/style.css
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
max-width: 40rem;
margin: 4rem auto;
padding: 0 1rem;
line-height: 1.6;
color: #1f2933;
background: #f8fafc;
}
h1 {
color: #2563eb;
font-size: 2.25rem;
margin-bottom: 0.5rem;
}
a {
color: #2563eb;
font-weight: 600;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

Step 2: Configure Nginx

The official image serves /usr/share/nginx/html with a default configuration. Replace that configuration with your own:

nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Compress text files before sending them
gzip on;
gzip_types text/css application/javascript image/svg+xml;
gzip_min_length 256;
# Clean URLs: /about serves about.html
location / {
try_files $uri $uri.html $uri/ =404;
}
# Let browsers cache stylesheets for a week
location /css/ {
expires 7d;
}
error_page 404 /404.html;
}
  • try_files $uri $uri.html $uri/ =404 tries the exact file, then the same name with .html added, then a folder — so /about serves about.html.
  • gzip compresses text files on the fly; gzip_min_length skips tiny files where compression does not help.
  • expires 7d tells browsers they may reuse stylesheets for a week without asking again.
  • error_page 404 /404.html shows your own page instead of Nginx’s plain one.

Step 3: The Dockerfile

Dockerfile
FROM nginx:1.31-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY site/ /usr/share/nginx/html/
HEALTHCHECK --interval=10s --timeout=3s \
CMD wget -qO /dev/null http://127.0.0.1/ || exit 1
.dockerignore
.git
*.md
  • nginx:1.31-alpine pins the Nginx version line, so a rebuild next month does not silently jump to a new major version.
  • Your config replaces the default one in /etc/nginx/conf.d/.
  • The site is copied into the image, so the image is complete on its own — no bind mounts needed in production.
  • HEALTHCHECK fetches the home page every 10 seconds. It uses 127.0.0.1 rather than localhost, because this config only listens on IPv4 and localhost can resolve to the IPv6 address.

Step 4: Build and Run

docker build -t portfolio-site:1.0 .
Output

Click Run to see what this code prints.

docker images portfolio-site
Output

Click Run to see what this code prints.

Run it with a restart policy, so it comes back automatically after a crash or a server reboot:

docker run -d --name portfolio -p 8080:80 --restart unless-stopped portfolio-site:1.0
Output

Click Run to see what this code prints.

Step 5: Test Every Feature

Open http://localhost:8080 in a browser, or test from the terminal. The home page and the clean /about URL:

curl -s http://localhost:8080/ | grep "<h1>"
curl -s http://localhost:8080/about | grep "<h1>"
Output

Click Run to see what this code prints.

A page that does not exist returns status 404 with the custom page:

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/blog
curl -s http://localhost:8080/blog | grep "<h1>"
Output

Click Run to see what this code prints.

The stylesheet carries caching headers — the Expires date is exactly seven days after the request:

curl -sI http://localhost:8080/css/style.css | grep -iE "^(HTTP|Content-Type|Expires|Cache-Control)"
Output

Click Run to see what this code prints.

Compression works when the browser asks for it (all browsers do). Even this small stylesheet shrinks by about a third; larger files typically shrink by 70% or more:

curl -s -H "Accept-Encoding: gzip" -o /dev/null -w "%{size_download} bytes compressed\n" http://localhost:8080/css/style.css
curl -s -o /dev/null -w "%{size_download} bytes uncompressed\n" http://localhost:8080/css/style.css
Output

Click Run to see what this code prints.

After the first health check has passed, the container reports itself healthy:

docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
Output

Click Run to see what this code prints.

You can also check the configuration inside the running container at any time:

docker exec portfolio nginx -t
Output

Click Run to see what this code prints.

Step 6: Update and Roll Back

Priya edits the paragraph in site/index.html, builds the result as a new version, and swaps the container. The old image, 1.0, stays on her machine:

docker build -q -t portfolio-site:1.1 . > /dev/null
docker rm -f portfolio
docker run -d --name portfolio -p 8080:80 --restart unless-stopped portfolio-site:1.1 > /dev/null
sleep 1
curl -s http://localhost:8080/ | grep "<p>"
Output

Click Run to see what this code prints.

If something is wrong with 1.1, rolling back is the same swap with the previous tag — no rebuild, no editing files on a server:

docker rm -f portfolio > /dev/null
docker run -d --name portfolio -p 8080:80 --restart unless-stopped portfolio-site:1.0 > /dev/null
sleep 1
curl -s http://localhost:8080/ | grep "<p>"
Output

Click Run to see what this code prints.

Mini Challenge · Put It in Order

Put Priya’s release routine in order.

  1. Build a new version: docker build -t portfolio-site:1.1 .
  2. Run a container from the new version
  3. Check the site — and if it is broken, run the previous version again
  4. Edit the site files
  5. Remove the old container

Extend This Project

  • Push the image to a registry (lesson 13) and run it on a cloud server.
  • Add security headers such as X-Content-Type-Options and Content-Security-Policy with add_header.
  • Build a React or Vue app in a Node build stage and copy its dist folder into this image (lesson 11).
  • Put the site behind Caddy for automatic HTTPS — see the reverse-proxy project.

Summary

You packaged a static website into a small, self-contained Nginx image with clean URLs, a custom 404 page, compression, caching and a health check — and practised the versioned release and rollback routine used for real deployments.