Parts 8 and 9 focused on keeping containers healthy and predictable. This time we move to the container that faces the browser: Nginx. In Docker, Nginx often does two jobs at once. It serves static files such as HTML, CSS, and JavaScript, and it forwards selected paths such as /api to another container.
How this post flows
- The smallest flow for launching Nginx inside Docker
- Gzip and cache headers that make static files fast
- What a reverse proxy is and how to configure it
- curl and browser tests that confirm behavior
- How to extend the pattern when static files and APIs live together
Reading card
- Estimated time: 18 minutes
- Prereqs: HTTP basics plus what headers do
- After reading: you can explain how to expose a frontend through one Nginx container safely.
Starting an Nginx container in Docker
Start with a tiny project layout so each file has a clear job.
my-nginx-project/
|- Dockerfile
|- docker-compose.yml
|- nginx.conf
`- public/
`- index.html
public/: files that Nginx serves directly.nginx.conf: web-server rules.Dockerfile: builds the image.docker-compose.yml: runs the container and publishes a port.
You only need these files for the smallest working static-site container.
FROM nginx:alpine
COPY ./public /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
services:
web:
build: .
ports:
- "8080:80"
Running docker compose up -d --build copies the local ./public folder into the container, applies nginx.conf, and exposes the site at http://localhost:8080.
Four Nginx directives carry most of the meaning here:
listen 80: which port Nginx listens on inside the container.root /usr/share/nginx/html: the base folder where Nginx looks for files.location: which rule should apply to a matching URL.proxy_pass: forward matching requests to another server.
If someone asks for /index.html, Nginx looks under /usr/share/nginx/html. Later, when a request starts with /api/, we can switch from "serve a file" to "forward this request to another container."
Serving static files with gzip and cache headers
Static sites feel fast when they transfer less data and let browsers reuse old assets safely. Start with these blocks.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json;
gzip_proxied any;
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on: enable compression for suitable responses.gzip_vary on: helps caches keep gzip and non-gzip versions separate.gzip_min_length 1024: skip tiny responses where compression is not worth much.gzip_types ...: limit compression to useful content types.gzip_proxied any: also compress responses that Nginx receives from an upstream app.
The location ~* \.(js|css|png|jpg|svg|woff2)$ line uses a pattern match. ~* means "match with a case-insensitive regex," so this block applies to file names ending in .js, .css, .png, and the other listed extensions.
Use the one-year cache rule only when your build tool creates hashed file names such as app.a1b2c3.js. In that case, a new deployment also creates a new file name, so long caching is safe. Do not apply this rule to HTML files, and do not use immutable for plain names such as app.js unless you are ready for browsers to keep stale files.
Student projects rarely have a CDN, so squeezing files at the web-server layer makes a noticeable difference.
Understanding the reverse proxy
Nginx does more than serve files. It can stand in front of another container and forward selected requests there. That pattern is called a reverse proxy.
First make the backend service visible in Compose.
services:
backend:
build: ./backend
ports:
- "4000:4000"
web:
build: .
ports:
- "8080:80"
depends_on:
- backend
Now Nginx can reach the backend by service name.
location ^~ /api/ {
proxy_pass http://backend:4000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
- Every
/api/...request goes to the Compose service namedbackend. - From inside the
webcontainer,backendworks like a DNS host name on the shared Compose network. - From the browser, everything still looks like
localhost:8080, which is why this pattern often avoids the commonlocalhost:3000tolocalhost:4000CORS problem.
One point often trips people up: the trailing slash at the end of proxy_pass changes how the path is forwarded.
location ^~ /api/ {
proxy_pass http://backend:4000/;
}
- Request
http://localhost:8080/api/healthis forwarded tohttp://backend:4000/health.
location ^~ /api/ {
proxy_pass http://backend:4000;
}
- Request
http://localhost:8080/api/healthis forwarded closer tohttp://backend:4000/api/health.
If a backend route looks doubled, missing, or shifted, check the proxy_pass slash before changing anything else.
Visualize the flow like this:
browser -> localhost:8080/api -> nginx container -> backend container
backend is the service name Compose registers on its internal network. Confirm it with docker compose ps, then connect with proxy_pass http://<service>:<port>/. From the host browser you do not visit backend:4000 directly. You visit localhost:8080, and Nginx forwards the API request for you.
The header lines matter too:
Host: passes the original host name.X-Real-IPandX-Forwarded-For: tell the backend which client IP connected.X-Forwarded-Proto: tells the backend whether the original request used HTTP or HTTPS.
For beginner projects, keep root as the default mental model. alias is useful later, but it adds one more path-mapping rule to think about.
Manual tests you should run
Change a config value and test immediately. These commands catch most mistakes early.
curl -I http://localhost:8080/index.html
curl -I http://localhost:8080/app.js
curl -I http://localhost:8080/api/health
curl -H 'Accept-Encoding: gzip' -I http://localhost:8080/app.js
index.html: confirm the site responds and returns the right content type.app.js: confirm cache headers and expiration dates for static assets./api/health: confirm the proxy can reach the backend.Accept-Encoding: gzip: ask Nginx for a compressed response and inspectContent-Encoding.
A 502 Bad Gateway usually means one of three things: the backend container is down, the service name is wrong, or the backend port in proxy_pass does not match the real app port.
When that happens, use this quick checklist:
docker compose ps
docker compose logs web
docker compose exec web curl http://backend:4000/health
docker compose ps: confirm both containers are running.docker compose logs web: read the Nginx error message.docker compose exec web curl ...: test whether Nginx can reach the backend from inside the container network.
Extending the pattern for static sites plus APIs
Serving static files might be enough at first, but projects quickly need paths like /api, /comments, or /auth to reach other services. In that setup Nginx plays two roles: it serves the frontend files directly, and it forwards selected routes to other containers.
That is the main pattern to keep: one public entry point, direct static-file delivery, and selective proxying for app routes. The next post moves into development volumes so you can change code and config without rebuilding every time.
💬 댓글
이 글에 대한 의견을 남겨주세요