Docker Hub has lots of popular images that are configured to run as root. This makes them very convenient for developers looking to get started quickly with the fewest complications. However for security, it’s recommended to run our containers as a non-root user. This exercise will walk you through some of the steps needed to run containers without root.

Start With an Image Using Root

To start, we will be working with one of the more popular images, nginx. Lets pull this image down and inspect it.

docker image pull nginx:1.16
docker image inspect nginx:1.16

Scrolling back, you should see that this image is not configured with any user:

...
  "Config": {
      "Hostname": "",
      "Domainname": "",
      "User": "",
      "AttachStdin": false,
      "AttachStdout": false,
      "AttachStderr": false,
      "ExposedPorts": {
          "80/tcp": {}
      },
...

The default, when there is no user, is to run as the root user.

Add a User

Lets create a Dockerfile based on this image, and add our own user. First, we want to figure out what Linux distribution this image uses. Many Linux distributions provide an /etc/os-release file with this information. Try outputting that:

docker container run --rm nginx:1.16 cat /etc/os-release

The method to create a user varies by Linux distribution. For Debian we can use useradd, e.g.:

useradd -u 5000 app

The above will create a user called app with UID 5000. It will also create a user group with the same name. On Linux distributions that do not ship with useradd, you can often use adduser with options that are specific to that distribution.

Create a Dockerfile that contains the following:

FROM nginx:1.16

RUN useradd -u 5000 app
Shortcut

You can run the following to skip the editor:

cat >Dockerfile <<EOF
FROM nginx:1.16

RUN useradd -u 5000 app
EOF

Now build the image (you can replace user with your own docker hub user id, but it shouldn’t matter for this exercise):

docker build -t user/nginx:1.16-1 .

Running as the User

Lets inspect the image we just created:

docker image inspect user/nginx:1.16-1

Note the user is still not set, we need to tell Docker to use this new user. Append the following to the Dockerfile:

USER app:app
Shortcut

You can run the following to skip the editor:

cat >Dockerfile <<EOF
FROM nginx:1.16

RUN useradd -u 5000 app
USER app:app
EOF

Build the new image:

docker build -t user/nginx:1.16-2 .

Inspect that image to verify it is using the app user:

docker image inspect user/nginx:1.16-2

And now try to run that image:

docker container run --rm user/nginx:1.16-2

Now we are starting to run into some issues. Some appear to be configuration issues, and others are permission issues.

Updating the Configuration

Lets extract the configuration files from this image to make some changes:

mkdir -p conf
docker container run user/nginx:1.16-2 tar -cC /etc/nginx . \
  | tar -xC conf

This creates a conf directory that contains the nginx.conf and conf.d/default.conf. We need to edit the nginx.conf to remove or comment out the user nginx; line (for those skipping vi, this will be included in a later shortcut):

# use a "#" to comment out the next line
# user nginx; 

Fixing File Permissions

In addition to removing the “user” line, we need to set paths for the following variables to be locations that we can write:

  • pid
  • client_body_temp_path
  • fastcgi_temp_path
  • proxy_temp_path
  • scgi_temp_path
  • uwsgi_temp_path

We will use /var/run/nginx/nginx.pid for the pid file, and /var/tmp/nginx/* for the other paths.

The resulting nginx.conf file looks like:

# user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
# Add the below pid
pid        /var/run/nginx/nginx.pid;


events {
    worker_connections  1024;
}


http {
    # add the below paths
    client_body_temp_path /var/tmp/nginx/client_body;
    fastcgi_temp_path /var/tmp/nginx/fastcgi_temp;
    proxy_temp_path /var/tmp/nginx/proxy_temp;
    scgi_temp_path /var/tmp/nginx/scgi_temp;
    uwsgi_temp_path /var/tmp/nginx/uwsgi_temp;

    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;
}

To use this configuration, our Dockerfile needs to copy it into the image. We also need to create the directories if they are missing and configure them to be owned by our app user. Update the Dockerfile to look like the following:

FROM nginx:1.16

RUN useradd -u 5000 app \
 && mkdir -p /var/run/nginx /var/tmp/nginx \
 && chown -R app:app /usr/share/nginx /var/run/nginx /var/tmp/nginx
COPY conf/nginx.conf /etc/nginx/nginx.conf
USER app:app
Shortcut

You can run the following to skip the editor:

cat >Dockerfile <<EOF
FROM nginx:1.16

RUN useradd -u 5000 app \
 && mkdir -p /var/run/nginx /var/tmp/nginx \
 && chown -R app:app /usr/share/nginx /var/run/nginx /var/tmp/nginx
COPY conf/nginx.conf /etc/nginx/nginx.conf
USER app:app
EOF

cat >conf/nginx.conf <<EOF
# user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
# Add the below pid
pid        /var/run/nginx/nginx.pid;


events {
    worker_connections  1024;
}


http {
    # add the below paths
    client_body_temp_path /var/tmp/nginx/client_body;
    fastcgi_temp_path /var/tmp/nginx/fastcgi_temp;
    proxy_temp_path /var/tmp/nginx/proxy_temp;
    scgi_temp_path /var/tmp/nginx/scgi_temp;
    uwsgi_temp_path /var/tmp/nginx/uwsgi_temp;

    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;
}
EOF

Handling Ports

Lets build the image with the updated configuration:

docker build -t user/nginx:1.16-3 .

And now try to run that image:

docker container run --rm user/nginx:1.16-3

That should have failed with the error message:

nginx: [emerg] bind() to 0.0.0.0:80 failed (13: Permission denied)

When applications aren’t root, they cannot listen on ports below 1024, so our web server listening on port 80 and 443 will not work. But, inside the container we can listen on a higher numbered port. And even better, with Docker we can still publish to a lower numbered port on the host, and map that high numbered port inside the container. To edit the port nginx listens on, edit the conf/conf.d/default.conf file and replace:

    listen 80;

with:

    listen 8080;

Then add the following line to the Dockerfile:

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

You can run the following to skip the editor:

cat >Dockerfile <<EOF
FROM nginx:1.16

RUN useradd -u 5000 app \
 && mkdir -p /var/run/nginx /var/tmp/nginx \
 && chown -R app:app /usr/share/nginx /var/run/nginx /var/tmp/nginx
COPY conf/nginx.conf /etc/nginx/nginx.conf
COPY conf/conf.d/default.conf /etc/nginx/conf.d/
USER app:app
EOF

cat >conf/conf.d/default.conf <<EOF
server {
    listen       8080;
    server_name  localhost;

    #charset koi8-r;
    #access_log  /var/log/nginx/host.access.log  main;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

    # proxy the PHP scripts to Apache listening on 127.0.0.1:80
    #
    #location ~ \.php\$ {
    #    proxy_pass   http://127.0.0.1;
    #}

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php\$ {
    #    root           html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts\$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    #
    #location ~ /\.ht {
    #    deny  all;
    #}
}
EOF

Lets rebuild our image with that change:

docker build -t user/nginx:1.16-4 .

And now try to run that image detached, with the port mapping, and a container name:

docker container run -d -p 80:8080 --name nginx user/nginx:1.16-4

And then test with a curl command:

curl http://localhost/

You should see the default nginx web site. You can also check with this link in your browser.

Using Volumes

What if we wanted to include our own static content? Developers often test by running their containers with a volume mount to avoid the need to rebuild the image for every change. Create an html directory and add some content to that directory with your own index.html. Here’s an example:

<!DOCTYPE html>
<html>
<head>
<title>Success!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Success!</h1>
<p>You've updated the nginx image.
</body>
</html>
Shortcut

You can run the following to skip the editor:

mkdir -p html
cat >html/index.html <<EOF
<!DOCTYPE html>
<html>
<head>
<title>Success!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Success!</h1>
<p>You've updated the nginx image.
</body>
</html>
EOF

Lets stop the current container, and replace it with a new one that has the volume mount:

docker container stop nginx
docker container rm nginx
docker container run -d -p 80:8080 --name nginx -v "$(pwd)/html:/usr/share/nginx/html" user/nginx:1.16-4

Then verify the container works:

curl localhost

Fortunately nginx only needs read access to these files. If our app needed to write to the directory, what would happen?

docker container exec nginx cp /usr/share/nginx/html/index.html /usr/share/nginx/html/index.bak

We get a permission denied again. We can see that the files on the host and inside the container are owned by the same UID/GID and with the same permissions. There’s no mapping from the host user to the container user when running on Linux. There are a few ways to handle this:

  1. Ensure the UID/GID of files on the host matches those of the container user.
  2. Fix permissions inside the image, and only use named volumes which initialize from the image contents.
  3. Avoid using host volumes on directories where the container will write.

Note that named volumes are only initialized when they are first created, so you only want to use these for persistent data, and not the contents of the image that you want to update with each new image.

Sudo Access

One last challenge users face when switching away from running everything as root is getting sudo access inside the container. Try to run an apt command and see what happens:

docker container exec -it nginx apt-get update

That fails without root access. If we try running sudo inside the container, what happens:

docker container exec -it nginx sudo apt-get update

Images are minimal, shipping only with the needed commands. And in containers sudo is not needed since it would be a security vulnerability (what’s the point of running as a user if that user can sudo to root) and there are better ways to get root inside of a container. The docker container exec command runs our command in the container namespace with the same settings like environment variables, working directory, and user, that the docker container run command uses to start the container. However, the docker container exec command gives options to override those settings, have a look at the help output to see how we can change the user:

docker container exec --help

Try running an apt-get update command inside the container as root instead of our app user.

Solution
docker container exec -it --user root nginx apt-get update

Summary

Looking over our steps, there was quite the process to configure an image to not use the root user. We needed to do the following:

  1. Create a user inside the container image.
  2. Tell Docker to use this user.
  3. Create and configure permissions on any user writable directories.
  4. Configure the application to write to user writable directories.
  5. Take extra precautions for any host volumes.
  6. Configure any listening ports to be above 1024 inside the container.
  7. Use docker exec args to run commands as root, rather than sudo.

Quiz

What line in a Dockerfile changes the user Docker uses to run commands? Select only one option

  • ( ) RUN useradd -u 5000 app
  • ( ) RUN chown -R app:app /usr/share/nginx
  • (x) USER app:app

What port restrictions exists when running commands as a non-root user? Select all that apply

  • Commands inside the container cannot listen on ports below 1024
  • Commands on the host cannot be published to ports below 1024
  • There are no port restrictions when using containers

Where do named volumes get their file permissions? Select all that apply

  • From the file permissions on the host
  • They are initialized with the file owner and permission from the image
  • Existing volumes will be overwritten when changes are made to the image
  • Existing volumes maintain the state from the previous usage