How to run nginx as non-privileged user with Docker

nginx is an open-source solution for web serving and reverse proxying your web application. You put it “in front” of your different services, and nginx can route the traffic to the correct url.
That’s useful for micro-services, for example.

Per default, nginx runs as root user. Why?
Only root processes can listen to ports below 1024. The default port for web applications is usually 80 or 443.

But that shouldn’t be a detriment to running Docker as a non-privileged user. After all, we can forward ports.

That’s the -p 80:8080 syntax that you might have seen in a docker run command.

You map the TCP port 8080 from the Docker container to port 80 on the Docker host (for example, your nginx webserver that listens to port 80).

For security reasons, it’s better to run a Docker container as a non-root user.

So, how can we achieve that?

Pull The Default Docker Image for nginx

Let’s say we have a React application and a backend written with Python and Flask.
During development, we might have used the official Docker node image and the official Docker python image.

Now, for deployment with nginx, we’ll use the official nginx Docker image.

I prefer the slimmed-down Debian distribution instead of the Alpine images that most people seem to use.
Most of the time, Alpine doesn’t contain essential tools and packages, and you’ll have to painstakingly debug why your installation doesn’t work.
The slim version of Debian (currently “Buster Slim”) ships with better defaults, and is only slightly larger.

For a better understanding, I recommend the article The best Docker base image for your Python application.

Your Dockerfile will start like this:

FROM nginx:1.17.6

The good news is that the official Docker build for nginx already installs a non-root user called nginx.

The bad news is that the nginx user doesn’t have all the permissions it needs to run your program.

Adjust nginx Configuration

You’ll need to replace the standard nginx configuration: the /etc/nginx/nginx.conf file and the /etc/nginx/conf.d/default.conf.

For the nginx.conf file, you should check how the default config looks like, and delete the user directive (first line of the file).

Here’s the /etc/ngnix/nginx.conf that works with Debian:

## user user; ## <- delete this line
worker_processes  1;
error_log  /var/log/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

Create your nginx configuration file, etc/nginx/conf.d/default.conf.

Example:

server {
  listen $PORT;

  root /usr/share/nginx/html;
  index index.html index.html;

  location / {
    try_files $uri /index.html =404;
  }

  location /auth {
    proxy_pass          https://127.0.0.1:5000;
    proxy_http_version  1.1;
    proxy_redirect      default;
    proxy_set_header    Upgrade $http_upgrade;
    proxy_set_header    Connection "upgrade";
    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-Host $server_name;
  }

  location /users {
    proxy_pass          https://127.0.0.1:5000;
    proxy_http_version  1.1;
    proxy_redirect      default;
    proxy_set_header    Upgrade $http_upgrade;
    proxy_set_header    Connection "upgrade";
    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-Host $server_name;
  }
}

The Python flask application runs on port 5000 inside the Docker container and has two routes: /auth and /users.
Only the front-end client will query these routes. Your users won’t access them.
nginx will serve the React application from the root route (/) to the public.

Now, copy those two customized configurations into your Docker container.
For the Flask application, we’ll also need to install Python.

FROM nginx:1.17.6

## install python3
RUN apt-get update && \
    apt-get install -y --no-install-recommends python3

COPY ./nginx.conf /etc/nginx/nginx.conf
COPY ./default.conf /etc/nginx/conf.d/default.conf

Add User Permissions

We need to give the nginx user permissions to several files.

Our working directory on the docker container will be /app. We’ll copy the source code from our local machine into that folder later.

The nginx user needs permission for the WORKDIR and also for /var/cache/nginx (cache), /etc/nginx/conf.d (for the nginx configuration), and the tmp folder (for pid and logging).

We have to create some of those files within the Dockerfile, otherwise, the container won’t run.

FROM nginx:1.17.6

RUN apt-get update && \
    apt-get install -y --no-install-recommends python3

COPY ./nginx.conf /etc/nginx/nginx.conf
COPY ./default.conf /etc/nginx/conf.d/default.conf

WORKDIR /app

## add permissions for nginx user
RUN chown -R nginx:nginx /app && chmod -R 755 /app && \
        chown -R nginx:nginx /var/cache/nginx && \
        chown -R nginx:nginx /var/log/nginx && \
        chown -R nginx:nginx /etc/nginx/conf.d
RUN touch /var/run/nginx.pid && \
        chown -R nginx:nginx /var/run/nginx.pid

USER nginx

That’s the bulk of our work.

Now we can copy our local code into the container, set environment variables, and run the start command.

Example:

FROM nginx:1.17.6

RUN apt-get update && \
    apt-get install -y --no-install-recommends python3

COPY ./nginx.conf /etc/nginx/nginx.conf
COPY ./default.conf /etc/nginx/conf.d/default.conf

WORKDIR /app

## add permissions
RUN chown -R nginx:nginx /app && chmod -R 755 /app && \
        chown -R nginx:nginx /var/cache/nginx && \
        chown -R nginx:nginx /var/log/nginx && \
        chown -R nginx:nginx /etc/nginx/conf.d
RUN touch /var/run/nginx.pid && \
        chown -R nginx:nginx /var/run/nginx.pid

## switch to non-root user
USER nginx

## add Python app
COPY . .

## set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV FLASK_ENV production
ENV SECRET_KEY $SECRET_KEY

## run server
CMD gunicorn -b 0.0.0.0:5000 manage:app --daemon && \
      sed -i -e 's/$PORT/'"$PORT"'/g' /etc/nginx/conf.d/default.conf && \
      nginx -g 'daemon off;'

When you run the container, don’t forget to use ports higher than 1024.

Example:

docker run -d -p 8007:5000 my-nginx-app

If you want to take a look at a working multi-stage docker build, you can check my deploy Dockerfile (for Heroku) for the Flask React Auth course by Testdriven.io.

Recap

Deploying nginx with Docker as non-root-user is possible, and improves the security of your Docker containers.

You have to jump through some hoops to set the correct permissions for the user, but then it works like a charm.

Further Reading