Dash0 Raises $110M Series B at $1B Valuation

  • 12 min read

How to Run Nginx in Docker

Getting Nginx running in Docker takes one command. Getting it running the way you actually want, with your own config, serving your own content, and not falling over the first time you lock down the container, takes a few more decisions. Most of them the official image makes for you in ways that aren't obvious until something breaks.

This article gets you from docker run to a production-shaped setup, and covers the two things that trip people up most: the template mechanism baked into the image, and the permission errors you hit the moment you try to run it as a non-root user.

The fastest possible start

The official image serves a default welcome page out of the box. This runs it and maps container port 80 to port 8080 on your host:

bash
1
docker run -d --name web -p 8080:80 nginx

You'll see the container ID printed back, confirming it started:

1
a3f7d2e1b4c589...

Hit http://localhost:8080 and you'll get the Nginx welcome page. The -d runs it detached, and --name web gives you something friendlier than a container ID to reference later.

That nginx with no tag pulls latest, which tracks the mainline branch (currently 1.31.x). For anything you'll run more than once, pin a tag instead of inheriting whatever latest points at the day you rebuild.

Which tag to use

The official image publishes a lot of tags, and the naming is more meaningful than it looks. The parts that matter:

  • nginx:1.31 / nginx:mainline: the mainline branch, where new features land. This is what latest points at.
  • nginx:1.30 / nginx:stable: the stable branch. Only bug and security fixes get backported, no new features. This is the safer choice for infrastructure you don't want surprising you.
  • -alpine: built on Alpine Linux instead of Debian, roughly 5 MB base versus ~70 MB. Smaller attack surface and faster pulls, at the cost of musl libc instead of glibc, which occasionally matters if you're compiling dynamic modules.
  • -otel: ships with the OpenTelemetry module preinstalled, so you can emit traces from Nginx without building it yourself. If you're instrumenting your stack, this variant saves you a custom build.
  • -perl: includes the Perl module, which was removed from the default images years ago.

For most production reverse proxies, nginx:1.30-alpine (pin the minor version, get the small image) is a sensible default. If you want distributed tracing out of Nginx, reach for nginx:1.30-alpine-otel.

Serving your own static content

The document root inside the container is /usr/share/nginx/html. Mount a host directory over it and Nginx serves your files:

bash
123456
mkdir -p ./site
echo "<h1>Served from Docker</h1>" > ./site/index.html
docker run -d --name web -p 8080:80 \
-v "$(pwd)/site:/usr/share/nginx/html:ro" \
nginx

The :ro mounts the volume read-only, which is the right call for static content. The web server has no reason to write to the directory it's serving.

For anything you're shipping rather than iterating on locally, bake the files into an image instead of mounting them, so the container is self-contained:

dockerfile
12
FROM nginx:1.30-alpine
COPY site/ /usr/share/nginx/html/

Custom configuration

There are two mount points worth knowing. The main config lives at /etc/nginx/nginx.conf, and it ends with an include /etc/nginx/conf.d/*.conf;. For most changes you don't want to replace the whole main config. You want to drop a server block into conf.d.

This mounts a single site config over the default one:

bash
123
docker run -d --name web -p 8080:80 \
-v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" \
nginx

A minimal reverse proxy default.conf pointing at another container on the same Docker network:

nginx
123456789101112
server {
listen 80;
server_name _;
location / {
proxy_pass http://app:3000;
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;
}
}

The app in proxy_pass resolves because Docker's embedded DNS lets containers on the same user-defined network find each other by name. Create the network with docker network create web and pass --network web to both containers, or let Compose handle it. Until the peer container is up on that network, nginx -t will report host not found in upstream "app"; that's the name lookup failing, not a syntax error in your config.

If you need to see what the default config looks like before overriding it, pull it out of the image without starting a full container:

bash
1
docker run --rm --entrypoint=cat nginx /etc/nginx/nginx.conf > nginx.conf

Environment variables in config with templates

Nginx config doesn't natively read environment variables, which surprises people coming from apps that do. The official image works around this with a template mechanism: any file in /etc/nginx/templates/ ending in .template gets run through envsubst at container start, with the result written to /etc/nginx/conf.d/.

Create default.conf.template:

nginx
123456789
server {
listen 80;
server_name ${SERVER_NAME};
location / {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_set_header Host $host;
}
}

Then mount it as a template and pass the values as environment variables:

bash
123456
docker run -d --name web -p 8080:80 \
-v "$(pwd)/default.conf.template:/etc/nginx/templates/default.conf.template:ro" \
-e SERVER_NAME=example.com \
-e BACKEND_HOST=app \
-e BACKEND_PORT=3000 \
nginx

One catch worth understanding: the image doesn't blindly substitute every ${...} in the file. Its entrypoint builds a list of the environment variables that are actually defined and passes only those to envsubst, so only your defined variables get replaced, whether you write them as $VAR or ${VAR}. Nginx's own runtime variables like $host are left untouched because they aren't environment variables, so you don't need to do anything special to protect them. The one thing to avoid is naming one of your own environment variables after an Nginx variable, since that name would then get substituted wherever it appears. If you want tighter control, set NGINX_ENVSUBST_FILTER to a prefix so only your own variables (say, APP_*) are eligible for substitution.

Running as a non-root user

Most production hardening efforts run into trouble here. The default image starts its master process as root so it can bind port 80 and write to /var/cache/nginx, then drops worker processes to an unprivileged user. The moment you try to run the whole thing as non-root, whether with --user, a USER directive, or a Kubernetes runAsNonRoot policy, you get errors like:

1
nginx: [emerg] open() "/run/nginx.pid" failed (13: Permission denied)

and, if you've also set a read-only root filesystem:

1
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)

You have two options. The easier one is nginxinc/nginx-unprivileged, which is preconfigured for exactly this: it listens on 8080 instead of 80, removes the user directive, and moves the PID file and temp paths to writable locations.

dockerfile
123
FROM nginxinc/nginx-unprivileged:1.30-alpine
COPY default.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080

Note the listen port changes to 8080. A non-root process can't bind ports below 1024 in most runtimes, so your server block and docker run -p mapping both need to target 8080.

If you'd rather stick with the official image, you have to make the writable paths writable yourself. Point the PID file and temp directories at /tmp in your config's http and top-level context:

nginx
12345678910
pid /tmp/nginx.pid;
http {
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
# ... rest of your http config
}

In most cases the unprivileged image is less work and less to get wrong, so start there before hand-rolling the paths.

A note on the read-only root filesystem

Setting read_only: true on the container is good hardening, but Nginx needs a handful of writable paths to function: the temp directories above plus the PID file. The standard pattern is a read-only root filesystem with tmpfs mounts for the paths that must be writable:

yaml
1234567891011
services:
web:
image: nginxinc/nginx-unprivileged:1.30-alpine
ports:
- "8080:8080"
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
volumes:
- ./default.conf:/etc/nginx/conf.d/default.conf:ro

You get an immutable container image with only the ephemeral scratch space it needs left writable, and that scratch space vanishes when the container stops.

Useful operational commands

Once it's running, these are the commands you'll reach for.

Test the config before reloading:

bash
1
docker exec web nginx -t
12
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Reload the config with zero downtime:

bash
1
docker exec web nginx -s reload

Print the full effective config (every included file, concatenated):

bash
1
docker exec web nginx -T

Follow access and error logs:

bash
1
docker logs -f web

Nginx in the official image writes access logs to stdout and error logs to stderr by default, so docker logs shows everything without extra configuration. That also means your existing Docker logging driver captures it automatically.

Final thoughts

A single container behind docker run is fine to start with, but production Nginx tends to accumulate the things that are easy to misconfigure silently: TLS termination, upstream health, connection limits, and request latency that only shows up under load. Nginx emits access logs, error logs, and (via the -otel image) traces, but only if something is collecting and correlating them.

Dash0 gives you OpenTelemetry-native observability for exactly that: ship Nginx's logs, request metrics, and distributed traces into one place and see them alongside the backends Nginx is proxying to, so a spike in 502s leads you straight to the upstream that's actually failing. Start a free trial and point your Nginx container's OpenTelemetry output at Dash0 to see your proxy and everything behind it in a single view. No credit card required.