Installing Nginx with apt works fine, but running it in Docker has some real advantages: you pick the exact Nginx version regardless of what Ubuntu ships, you can run different versions side by side, upgrades and rollbacks become a one-line change, and the whole setup travels to any machine as a couple of files.
This tutorial shows you how to run Nginx in Docker on Ubuntu, properly. You will pull a specific pinned version of the official image, serve your own static content, mount a custom configuration, and then wrap everything in a Docker Compose file so the setup is reproducible. Along the way we cover the parts most quick-start guides skip: how the image tags work, how to reload configuration without dropping connections, and where the logs went.
This guide is for developers and sysadmins who know basic Linux and have at least seen Docker before. If Docker itself is new to you, start with my introduction first: Getting Started with Docker and Docker Compose on Ubuntu.
Understanding the Official Nginx Image and Its Tags
The official nginx image on Docker Hub is one of the most pulled images in existence, and its tag system is the first thing to understand because it decides what you actually run:
nginx:latestpoints to the current mainline release. It changes underneath you every time a new version ships.nginx:stablepoints to the current stable branch, and also moves over time.nginx:1.29pins the minor version: you get patch updates within 1.29 when you re-pull, but never a surprise jump to a new minor.nginx:1.29.0pins the exact release. Nothing changes until you change the tag.- Variant suffixes combine with all of the above:
nginx:1.29-alpineis the same Nginx built on Alpine Linux (a much smaller image), andnginx:1.29-alpine-slimstrips it down further.
Older tags stay available forever, which is why you can still run docker pull nginx:1.25 today and get the last 1.25.x build. That is useful when you need to reproduce an old environment, but for anything new, pin a current version instead: 1.25 stopped receiving patches long ago.
The rule of thumb: never deploy latest, pin at least the minor version. You want upgrades to happen when you decide, not when a build cache expires.
Prerequisites
- Ubuntu 22.04 or 24.04 with Docker Engine and the Docker Compose plugin installed (
docker --versionanddocker compose versionshould both work) - A user in the
dockergroup, or usesudowith every docker command - Port 8080 free on the host for testing (we avoid 80 in case a host Nginx is already running)
- Basic command line knowledge
Step 1: Pull a Pinned Nginx Image
Pull the image with an explicit version tag:
docker pull nginx:1.29-alpine
1.29-alpine: Pulling from library/nginx
9824c27679d3: Pull complete
6710e1c69ad2: Pull complete
...
Status: Downloaded newer image for nginx:1.29-alpine
docker.io/library/nginx:1.29-alpine
Confirm what you have and how big it is:
docker images nginx
REPOSITORY TAG IMAGE ID CREATED SIZE
nginx 1.29-alpine 5a2ab1f0f680 2 weeks ago 52.9MB
The Alpine variant is around 50 MB versus roughly 190 MB for the Debian-based image. For serving static files and proxying, the Alpine build is all you need.
Step 2: Run the Container and Understand What Happened
Start a container, publishing container port 80 to host port 8080:
docker run -d --name web -p 8080:80 nginx:1.29-alpine
Test it:
curl -I http://localhost:8080
HTTP/1.1 200 OK
Server: nginx/1.29.0
Content-Type: text/html
That is the Nginx welcome page, served from inside the container. Two things are worth understanding before moving on.
First, networking: -p 8080:80 maps host port 8080 to container port 80. Inside the container Nginx listens on plain port 80 like always; the host side is your choice.
Second, logs: the official image symlinks the access and error logs to stdout and stderr, so there is no log file to hunt for. You read them with:
docker logs -f web
172.17.0.1 - - [04/Jul/2026:09:14:02 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/8.5.0"
Clean up this test container before the next step:
docker rm -f web
Step 3: Serve Your Own Static Content
The image serves whatever is in /usr/share/nginx/html. Instead of baking files into an image, mount a host directory there. Create some content:
mkdir -p ~/nginx-docker/html
echo '<h1>Hello from Nginx in Docker</h1>' > ~/nginx-docker/html/index.html
Run with a read-only bind mount:
docker run -d --name web -p 8080:80 \
-v ~/nginx-docker/html:/usr/share/nginx/html:ro \
nginx:1.29-alpine
curl http://localhost:8080
<h1>Hello from Nginx in Docker</h1>
The :ro suffix mounts the directory read-only inside the container. Nginx only needs to read the files, and a web server that cannot write to its own document root is one small class of attack eliminated for free.
Edit index.html on the host and the change is live immediately, no restart needed, because the container reads the same files.
Step 4: Mount a Custom Configuration
The default config is fine for the welcome page, but real usage means your own server block. The image loads every *.conf file in /etc/nginx/conf.d/, so the cleanest approach is mounting your config there.
Create the config on the host:
mkdir -p ~/nginx-docker/conf.d
nano ~/nginx-docker/conf.d/default.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location /health {
access_log off;
return 200 "ok\n";
}
}
Naming it default.conf deliberately replaces the config that ships in the image, so you do not end up with two competing server blocks. Recreate the container with both mounts:
docker rm -f web
docker run -d --name web -p 8080:80 \
-v ~/nginx-docker/html:/usr/share/nginx/html:ro \
-v ~/nginx-docker/conf.d:/etc/nginx/conf.d:ro \
nginx:1.29-alpine
Verify the custom config is active:
curl http://localhost:8080/health
ok
When you change the config later, validate and reload without recreating the container and without dropping connections:
docker exec web nginx -t
docker exec web nginx -s reload
nginx -t inside the container checks the syntax exactly like on a normal install; make it a reflex before every reload.
Step 5: Put It All in Docker Compose
Flags on a docker run line are easy to lose. Compose turns the whole setup into a file you can version control. Create the file:
nano ~/nginx-docker/docker-compose.yml
services:
web:
image: nginx:1.29-alpine
container_name: web
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
- ./conf.d:/etc/nginx/conf.d:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
interval: 30s
timeout: 3s
retries: 3
Two additions over our docker run version: restart: unless-stopped brings the container back after a crash or a host reboot, and the healthcheck makes Docker itself probe the /health endpoint we configured, so docker ps tells you whether Nginx is actually serving, not just running.
Replace the old container with the Compose-managed one:
docker rm -f web
cd ~/nginx-docker
docker compose up -d
docker compose ps
NAME IMAGE STATUS PORTS
web nginx:1.29-alpine Up 15 seconds (healthy) 0.0.0.0:8080->80/tcp
Upgrading Nginx later is now a two-line change: edit the image tag in the file (say, to 1.29.1-alpine or the next stable minor), then:
docker compose pull
docker compose up -d
Compose recreates only what changed. Rolling back is the same operation with the old tag.
Common Mistakes and Troubleshooting
bind: address already in use. Something on the host already listens on your chosen port, very often an apt-installed Nginx. Either stop it (sudo systemctl disable --now nginx) or publish a different host port (-p 8081:80).
403 Forbidden on your own content. The container cannot read the mounted files. Check permissions on the host directory (chmod -R a+r ~/nginx-docker/html) and make sure the mount path is a directory, not a file you mistyped.
Container exits immediately after start. Almost always a config syntax error. Read the reason with docker logs web, fix the file on the host, and start again. You can pre-validate any config change with docker exec web nginx -t while the old container still runs.
You mounted a single file and edits do not apply. Bind-mounting one file (instead of its directory) binds to the file’s inode; editors that save by replacing the file break the mount. Mount the whole conf.d directory instead, as we did above.
curl localhost:8080 works but nothing loads from other machines. Check the host firewall. With UFW, allow the port: sudo ufw allow 8080/tcp.
Changes to docker-compose.yml do nothing. Compose applies changes only on docker compose up -d, not on save. Run it again after editing.
Best Practices
- Pin the version, and pin it in one place.
nginx:1.29-alpinein your Compose file is the single source of truth. Review release notes and bump it deliberately. - Mount everything read-only (
:ro) unless Nginx genuinely must write, which for static serving and proxying it never does. - Keep content and config on the host (or in named volumes), never
docker cpinto a running container. Containers are disposable; your data and config should survive them. - Let logs go to stdout/stderr and manage them with Docker’s log rotation (
--log-opt max-size=10m --log-opt max-file=3or thelogging:key in Compose) instead of writing files inside the container. - Scan the image you deploy. Version-pinned images age; scan them regularly so you know when a rebuild is due. My guide on Scanning Container Images with Trivy covers this.
- For HTTPS with multiple sites, consider a dedicated reverse proxy container in front of your services. I covered a very convenient option in Setup Traefik Reverse Proxy with Docker on Ubuntu.
Conclusion
You now run Nginx the container way: a pinned official image, content and configuration mounted read-only from the host, zero-downtime reloads with nginx -s reload, and a Docker Compose file that makes the whole thing reproducible on any machine with Docker installed. You also know what the tag system means, so docker pull nginx:1.25 versus nginx:1.29-alpine is a deliberate choice rather than a copy-pasted one.
From here, natural next steps are putting real sites behind it with proper redirect rules, see How to Create Nginx Redirect Configuration, and tuning what you serve with Gzip Compression and Browser Caching.