Second project from my CodeAlpha DevOps internship. The task was to deploy and manage a web server inside Docker, learn the container lifecycle, and monitor the container's health.
Getting NGINX running in Docker is one command. So I tried to set it up the way you'd want it in production: not running as root, locked down, with a proper health check, and able to recover on its own if it crashes.
Repo: github.com/mikegbenga/CodeAlpha_DockerWebServer
What I built
- An NGINX container based on
nginx-unprivileged, running as user 101 - A
/healthzendpoint that Docker checks every 15 seconds - A read-only filesystem, no Linux capabilities, and CPU and memory limits
-
manage.sh, a script for the whole container lifecycle, with tests and a crash test - Optional monitoring with the NGINX Prometheus exporter and Prometheus
- GitHub Actions that lint, build, run, test and scan the image on every push
This is the page it serves. The status line at the bottom calls /healthz from the browser and shows the request ID that NGINX adds to every response.
The Dockerfile
FROM nginxinc/nginx-unprivileged:1.27-alpine
USER 0
RUN rm -f /etc/nginx/conf.d/default.conf
COPY --chown=nginx:nginx nginx/nginx.conf /etc/nginx/nginx.conf
COPY --chown=nginx:nginx nginx/conf.d/ /etc/nginx/conf.d/
COPY --chown=nginx:nginx site/ /usr/share/nginx/html/
RUN nginx -t
USER 101
EXPOSE 8080
STOPSIGNAL SIGQUIT
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD ["wget", "-q", "--spider", "http://127.0.0.1:8080/healthz"]
CMD ["nginx", "-g", "daemon off;"]
What each part is for:
-
nginx-unprivilegedlistens on 8080 and runs as a normal user. A non-root user can't bind to ports below 1024, which is why it's 8080 and not 80. -
RUN nginx -tchecks the config during the build. A typo fails the build instead of the deploy. This saved me more than once. -
STOPSIGNAL SIGQUITmakesdocker stopshut NGINX down gracefully, so requests in progress get to finish. -
HEALTHCHECKis what makesdocker psshow(healthy)or(unhealthy)instead of just "Up".
Running it safely (docker-compose.yml)
services:
web:
build: .
image: codealpha/webserver:latest
ports:
- "8080:8080"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/healthz"]
interval: 15s
timeout: 3s
retries: 3
read_only: true
tmpfs:
- /tmp:size=16m
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
cpus: "0.50"
memory: 128M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
-
read_only: true: nothing in the container can be changed, even if someone gets in. -
tmpfs: /tmp: NGINX still needs somewhere to write temp files, so I pointed all its temp paths at/tmpinnginx.conf. -
cap_drop: [ALL]andno-new-privileges: the process gets no special Linux permissions and can't gain any. -
deploy.resources.limits: one container can't eat the whole server. - Log rotation, so
docker logscan't fill the disk.
The NGINX config
Main settings in nginx.conf:
pid /tmp/nginx.pid;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
log_format json escape=json '{"time":"$time_iso8601","request_id":"$request_id",'
'"method":"$request_method","uri":"$request_uri",'
'"status":$status,"request_time":$request_time}';
access_log /dev/stdout json if=$loggable; # health checks are not logged
server_tokens off;
gzip on;
limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
And the server block in conf.d/site.conf:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-Request-ID $request_id always;
location = /healthz {
access_log off;
default_type application/json;
return 200 '{"status":"ok","server":"nginx"}';
}
location = /nginx_status {
stub_status;
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
deny all;
}
location ~ /\. { deny all; return 404; } # .git, .env and other hidden files
There's also a custom 404 page:
One thing that tripped me up: I originally had listen [::]:8080; as well. On a machine with IPv6 turned off, NGINX refused to start with Address family not supported by protocol. I removed that line.
Another one: in NGINX, if a location block has its own add_header, it replaces the headers from the server block instead of adding to them. So the security headers have to be repeated in those locations.
Checking it works
I ran the config on a real NGINX and checked it with curl:
The lifecycle script
scripts/manage.sh wraps the Docker commands so the whole lifecycle is one script:
./scripts/manage.sh build # build the image
./scripts/manage.sh run # docker run with all the hardening flags, then wait until healthy
./scripts/manage.sh status # status, restart count, OOMKilled, exit code
./scripts/manage.sh health # health state + last 5 probe results
./scripts/manage.sh logs # follow logs
./scripts/manage.sh stats # CPU and memory against the limits
./scripts/manage.sh stop # graceful stop
./scripts/manage.sh test # 8 functional + security checks
./scripts/manage.sh crash-test # kill the server and check it comes back
./scripts/manage.sh clean # remove container and image
The test command checks the pages, the health endpoint, the 404 page, that .env is blocked, the security headers, that the root filesystem is read-only, and that it's not running as root.
The crash test
This was the most interesting part for me. My first idea was docker exec … kill -9 1. It didn't work, because PID 1 inside a container ignores SIGKILL sent from inside its own namespace. Asking NGINX itself to stop works:
docker exec codealpha-web nginx -s stop
The container exits, the unless-stopped restart policy starts it again, and the restart count goes up. Then the script waits until it's healthy again.
One more thing worth knowing: Docker on its own does not restart a container just because it's unhealthy. It only restarts containers that exit. That's why there's also scripts/health-monitor.sh, which restarts the container after 3 failed checks in a row. Swarm or Kubernetes would do this for you.
Container lifecycle cheat sheet
| Stage | Command | What happens |
|---|---|---|
| Build | docker build -t codealpha/webserver . |
Layers are cached; nginx -t runs |
| Run | docker run -d --name codealpha-web -p 8080:8080 … |
Created and started |
| Health | docker inspect -f '{{.State.Health.Status}}' codealpha-web |
starting → healthy / unhealthy
|
| Pause |
docker pause / docker unpause
|
Processes frozen, port still bound |
| Stop | docker stop -t 10 codealpha-web |
SIGQUIT, then SIGKILL after 10 s |
| Restart | docker restart codealpha-web |
Stop + start |
| Remove | docker rm -f codealpha-web |
Container gone; the image stays |
Monitoring
docker compose --profile monitoring up -d --build
This also starts the nginx-prometheus-exporter, which reads /nginx_status, and Prometheus on port 9090. Prometheus has two alert rules: NginxDown and too many active connections.
CI with GitHub Actions
Every push runs .github/workflows/docker-ci.yml:
To be honest, the first two runs failed:
- I'd pinned
aquasecurity/trivy-action@0.28.0, which doesn't exist. The releases use avprefix, sov0.36.0works. - ShellCheck complained about
A && B || C(SC2015), because if B fails, C runs even though A succeeded. I changed those lines to properif/else.
Third run passed in 1 minute 9 seconds.
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
port is already allocated |
Something else is on 8080 | HOST_PORT=8081 ./scripts/manage.sh run |
Read-only file system error |
A temp path isn't on the tmpfs | Keep the *_temp_path /tmp/... lines |
bind() to 0.0.0.0:80 failed (13: Permission denied) |
Non-root can't use port 80 | Listen on 8080, map -p 80:8080
|
Stuck on starting
|
The probe can't reach /healthz
|
docker exec codealpha-web wget -qO- http://127.0.0.1:8080/healthz |
OOMKilled: true |
Hit the memory limit | Raise the limit |
What I learned
- A health check tells you a lot more than "the container is running".
- Most of the hardening is just a few lines of config.
- Checking the config during the build catches mistakes early.
- You don't really know self-healing works until you crash it on purpose.
Next in the series: Jenkins Remoting, running builds on other machines.
Thanks to CodeAlpha for the internship.





Top comments (0)