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:
- Ensure the UID/GID of files on the host matches those of the container user.
- Fix permissions inside the image, and only use named volumes which initialize from the image contents.
- 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:
- Create a user inside the container image.
- Tell Docker to use this user.
- Create and configure permissions on any user writable directories.
- Configure the application to write to user writable directories.
- Take extra precautions for any host volumes.
- Configure any listening ports to be above 1024 inside the container.
- Use
docker exec
args to run commands as root, rather thansudo
.
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