Featured image: Rootless containers and Node.js

By default, most containers are run as the root user. It is much easier to install dependencies, edit files, and run processes on restricted ports when they run as root. As is usually the case in computer science, though, simplicity comes at a cost. In this case, containers run as root are more vulnerable to malicious code and attacks. To avoid those potential security gaps, Red Hat OpenShift won't let you run containers as a root user. This restriction adds a layer of security and isolates the containers.

This article shows you how to run a JavaScript front-end application in a rootless container. The example builds on the code from my previous article, Making environment variables accessible in front-end containers.

Building a rootless container

Here is the Dockerfile we'll use for our example. As demonstrated in my previous article, you can use this Dockerfile to access environment variables from your Angular, React, or Vue.js applications:

FROM node:14

ENV JQ_VERSION=1.6
RUN wget --no-check-certificate https://github.com/stedolan/jq/releases/download/jq-${JQ_VERSION}/jq-linux64 -O /tmp/jq-linux64
RUN cp /tmp/jq-linux64 /usr/bin/jq
RUN chmod +x /usr/bin/jq

WORKDIR /app
COPY . .
RUN jq 'to_entries | map_values({ (.key) : ("$" + .key) }) | reduce .[] as $item ({}; . + $item)' ./src/config.json | ./src/config.tmp.json && mv ./src/config.tmp.json config.json
RUN npm install && npm run build

FROM nginx:1.17
# Angular: ENV JSFOLDER=/usr/share/nginx/html/*.js
# React: ENV JSFOLDER=/usr/share/nginx/html/static/js/*.js
# VueJS: ENV JSFOLDER=/usr/share/nginx/html/js/*.js
COPY ./start-nginx.sh /usr/bin/start-nginx.sh
RUN chmod +x /usr/bin/start-nginx.sh
WORKDIR /usr/share/nginx/html
# Angular: COPY --from=0 /app/dist/ .
# React: COPY --from=0 /app/build .
# VueJS: COPY --from=0 /app/dist .
ENTRYPOINT [ "start-nginx.sh" ]

This container uses two stages to build the final container. In the first stage, it uses the node:14 image, which is running as root. The build process will eventually discard this container, so you don't need to worry about it.

The second-stage container is the one that needs to be secured. The nginx base image is currently running as root, mainly so that it can run on port 80, which requires privileged access to enable. Once this container is ready to run rootless, it will run on port 8080. You will need to change the default nginx configuration for the container to run rootless. You will also need to make sure that the server itself is running as an unprivileged user. Finally, the user will need access to several files and folders.

Let's get started with making this container a rootless one.

Create the NGINX configuration file

The first step is to create a new configuration file for NGINX. You can start with the most basic configuration file needed to run NGINX and build it from there:

worker_processes auto;
events {
  worker_connections 1024;
}
http {
  include /etc/nginx/mime.types;
  server {
    server_name _;
    index index.html;
    location / {
      try_files $uri /index.html;
      }
    }
}

Next, you need to change the server settings to run on port 8080 instead of the default port 80. You'll also need to change the default path that NGINX uses to serve files:

http {
  ...
  server {
    listen 8080;
    ...
    location / {
      root /code;
      ...
    }
  }
}

The final nginx.conf file should look like this:

worker_processes auto;
events {
  worker_connections 1024;
}
http {
  include /etc/nginx/mime.types;
  server {
    listen 8080;
    server_name _;
    index index.html;
    location / {
      root /opt/app;
      try_files $uri /index.html;
    }
  }
}

Edit the Dockerfile

Now that you have a new NGINX configuration file that lets the server run as a regular user, it's time to edit the Dockerfile. This modified container will run as user nginx. In this case, the NGINX base images provide the non-root user.

In the second step of your build, right after you've specified your base image with the FROM statement, you can copy your new NGINX configuration file to overwrite the default one. Then, create an /opt/app folder and change its ownership:

FROM nginx:1.17
COPY ./nginx.conf /etc/nginx/nginx.conf
RUN mkdir -p /opt/app && chown -R nginx:nginx /opt/app && chmod -R 775 /opt/app

Don't forget to change the JSFOLDER variable. This will ensure that your environment variables are still injected by the bash script.

# Angular
# ENV JSFOLDER=/opt/app/*.js
# React
# ENV JSFOLDER=/opt/app/static/js/*.js
# VueJS
# ENV JSFOLDER=/opt/app/js/*.js

Change the file ownership

Next, you need to give NGINX access to run a series of files and folders for caching and logging purposes. You can change the ownership of all of them in a single RUN statement, using ampersands to chain the commands:

RUN chown -R nginx:nginx /var/cache/nginx && \
   chown -R nginx:nginx /var/log/nginx && \
   chown -R nginx:nginx /etc/nginx/conf.d

NGINX also requires an nginx.pid file. This file does not exist yet, so you need to create it and assign ownership to the nginx user:

RUN touch /var/run/nginx.pid && \
   chown -R nginx:nginx /var/run/nginx.pid

Update the group and permissions

Finally, you will change the group for those files and folders and change the permissions so that NGINX can read and write the folders:

RUN chgrp -R root /var/cache/nginx /var/run /var/log/nginx /var/run/nginx.pid && \
   chmod -R 775 /var/cache/nginx /var/run /var/log/nginx /var/run/nginx.pid

Switch to the rootless user

Now that you've adjusted all the permissions, you can tell Docker to switch over to the nginx user using the USER statement. You can then copy the files from the builder step into the /opt/app folder using the --chown flag, which makes the files accessible by the nginx user. Finally, you will tell Docker that this new image uses a different port. Use the EXPOSE statement for port 8080:

USER nginx
WORKDIR /opt/app
COPY --from=builder --chown=nginx  .
RUN chmod -R a+rw /opt/app
EXPOSE 8080

The final front-end Dockerfile will look like this:

FROM node:14

ENV JQ_VERSION=1.6
RUN wget --no-check-certificate https://github.com/stedolan/jq/releases/download/jq-${JQ_VERSION}/jq-linux64 -O /tmp/jq-linux64
RUN cp /tmp/jq-linux64 /usr/bin/jq
RUN chmod +x /usr/bin/jq

WORKDIR /app
COPY . .
RUN jq 'to_entries | map_values({ (.key) : ("$" + .key) }) | reduce .[] as $item ({}; . + $item)' ./src/config.json | ./src/config.tmp.json && mv ./src/config.tmp.json config.json
RUN npm install && npm run build

FROM nginx:1.17
# Angular
# ENV JSFOLDER=/opt/app/*.js
# React
# ENV JSFOLDER=/opt/app/static/js/*.js
# VueJS
# ENV JSFOLDER=/opt/app/js/*.js
COPY ./nginx.conf /etc/nginx/nginx.conf
RUN mkdir -p /opt/app && chown -R nginx:nginx /opt/app && chmod -R 775 /opt/app
RUN 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
RUN chgrp -R root /var/cache/nginx /var/run /var/log/nginx /var/run/nginx.pid && \
   chmod -R 775 /var/cache/nginx /var/run /var/log/nginx /var/run/nginx.pid
COPY ./start-nginx.sh /usr/bin/start-nginx.sh
RUN chmod +x /usr/bin/start-nginx.sh

EXPOSE 8080
WORKDIR /opt/app
# Angular
# COPY --from=0 --chown=nginx /app/dist/ .
# React
# COPY --from=0 /app/build .
# VueJS
# COPY --from=0 /app/dist .
RUN chmod -R a+rw /opt/app
USER nginx
ENTRYPOINT [ "start-nginx.sh" ]

Your new Dockerfile is ready to go! You can test it out by using a docker build followed by a docker run. Don't forget to map the new port since this container doesn't run on port 80 anymore:

docker build -t frontend .
docker run -d -p 8080:8080 --rm --name front -e ENV=prod -e BASE_URL=/api frontend

Conclusion

You now have everything needed to run your JavaScript front end in a secure container. You can reuse the image we developed in this article for all of your JavaScript projects, whether you are using Angular, React, or Vue.js. The front end not only runs securely but also lets you inject environment variables into your code. You can find all the examples and source code from this article on GitHub.