DEV Community

utkarsh srivastava
utkarsh srivastava

Posted on

How to Containerize a Full-Stack App (Part 2)-Optimizing the Frontend with Multi-Stage Docker Builds.

Series Navigation:

Intro

This is Part 2 of our series. If you haven't set up the local Full-Stack Todo app yet, check out Part 1: Local Setup to get your environment ready.

In Part 2, we will guide you on how to containerize our Frontend application. In the upcoming blog post, I will explain the rest of the details.

If you are a frontend developer and only want to know how to Dockerize your frontend, this section is very important. Also, you can use this Docker image in production as well.

Note: This Setup you can use for any React app that uses Vite. There may be a few changes if you are using some other bundler. But the basic idea of deployment remains the same.

Here is what we will cover:

  1. Frontend Code Walkthrough: A brief overview of our React app structure.
  2. Generating the Production Build: How to prepare the app for deployment.
  3. Deployment Architecture: High-level view of our deployment
  4. From Source Code to Container: Visualizing the transformation process
  5. Writing Our First Dockerfile: Writing the step-by-step build instructions.
  6. Creating nginx configuration file: Configuring nginx to serve our static files
  7. Building Docker Image: Building Docker Image and Running it.
  8. Managing Environment Variables: Configuring API URLs and secrets securely.
  9. Docker File optimization: Intro to Docker Image Optimization.
  10. Multi-Stage Docker file: The professional way to shrink image size.
  11. Securing Frontend Docker Container: Restricting folder permissions and user access.
  12. Bonus: Extra observation on nginx.
  13. Wrap Up

1. Frontend Code Walkthrough: A brief overview of our React app structure.

Prefer Video? Watch Here

Our React app was built via vite. It had a folder structure like below

Frontend folder structure

Where

frontend/
β”œβ”€β”€ public/                 # Static assets (favicons, manifest, etc.)
β”œβ”€β”€ src/                    # our todo  application source code
|β€”β€”β€” .env                   # environment file    
β”œβ”€β”€ .env-example            # Template for environment variables
β”œβ”€β”€ .gitignore              # Files/folders to be ignored by Git
β”œβ”€β”€ eslint.config.js        # Linting rules to keep code consistent
β”œβ”€β”€ index.html              # Entry Point. Vite treats this file as source code. It contains a <script type="module" src="..."> tag that points to your React entry file (usually src/main.tsx).
β”œβ”€β”€ package-lock.json       # Exact versions of installed dependencies
β”œβ”€β”€ package.json            # Project metadata and script definitions
β”œβ”€β”€ README.md               # Project documentation
β”œβ”€β”€ tsconfig.app.json       # TypeScript config for the frontend app
β”œβ”€β”€ tsconfig.json           # Main TypeScript configuration
β”œβ”€β”€ tsconfig.node.json      # TypeScript config for Node-based tools (like Vite)
└── vite.config.ts          # Vite-specific settings and plugins
Enter fullscreen mode Exit fullscreen mode

2. Generating the Production Build: How to prepare the app for deployment.

Prefer Video? Watch Here

So, to deploy this React app. We first need to create a bundle of it by running the build command.

So we need to run

npm run build
Enter fullscreen mode Exit fullscreen mode

Once you run this command inside the frontend folder.
You can see the dist folder, which looks like the following structure

Frontend Dist folder Structure

 dist/
 β”œβ”€β”€ assets/
  β”‚   β”œβ”€β”€ index-DiW657_O.css # All your CSS (minified)
  β”‚   └── index-D9fK2_zX.js  # All your React code + libraries (minified)
 β”œβ”€β”€ index.html # The main entry point (minified)
 └── vite.svg # Copied directly from public/
Enter fullscreen mode Exit fullscreen mode

By running this command, Vite took our development code and transformed it into a highly optimized bundle designed for web browsers. Because our browser does not understand JSX or TypeScript. So Vite converts our code into standard JavaScript.

Vite creates a new directory in our root folder called dist/

This folder contains everything your web server needs:

  • index.html: The entry point, now updated with the correct paths to your hashed JS and CSS files.
  • assets/: A folder containing your minified .js and .css files.

Now to test our build locally, run the command

npm run preview
Enter fullscreen mode Exit fullscreen mode

This starts a local server that serves the dist folder, allowing you to see exactly how your app will perform in production.

Now go to your browser and type http://localhost:4173/

You are able to see our todo app, so it shows that our build was created successfully, and it is also running perfectly.

Now kill that server by running ctrl + c or cmd + c in mac.

3. Deployment Architecture: High-level view of our React app deployment.

Prefer Video? Watch Here

Before we proceed, we first need to understand how our React app actually runs in production.

So in the code walkthrough section, we ran the npm run build command in order to build our project. Then in order to test that build
Locally, we ran npm run preview command. This command spins up a web server and serves the dist folder.

We need to set up a similar environment for our production.

For the web server, we will use nginx because it is mostly used in production. And nginx will handle upcoming requests and serve the dist folder.

See the diagram to get a better idea

how frontend work in production

In summary: Nginx takes a web request, finds the correct static file on the disk, and hands it back to the browser.

For Production, we need:

  • A web server, i.e., nginx
  • dist folder (bundle of our application)

4. From Source Code to Container: Visualizing the transformation process.

Prefer Video? Watch Here

Before we proceed, I would like to provide a high-level overview of containerization.

Containerization involves a few steps

Stage 1: Our Source Code
Stage 2: Take our source code and compile our source code and configuration into a package
Stage 3: The result of Stage 2 is a static Docker Image
Stage 4: This is the live, running instance of the created Docker image. Now we can access our application

architecture for containerizing process

I hope the above diagram gives us some idea.

So I think our responsibility is to create a Docker image for our application.

But how do we provide our source code to Docker so that it can build the Docker image?

Also, how does it understand what it needs to do to build our project?

So here comes Dockerfile to rescue us.

4.1 What is a Dockerfile?

"A Dockerfile is a simple text file that acts as a blueprint for building a Docker Image. It contains a set of sequential instructions that tell Docker exactly how to set up the environment, copy your code, and get your application running."

We need to create a Dockerfile in our frontend directory. In the Dockerfile, we define everything that is required to build our project.

So, how do we create our Docker file, and what content will we put inside it so that Docker understands it and create docker image by reading it?

5. Writing Our First Dockerfile: Writing the step-by-step build instructions

Prefer Video? Watch Here

Go to the frontend directory if you are not and inside that directory, create a file with the name Dockerfile.

Open Dockerfile.

So to build our project, we need npm (Node Package Manager)
And it comes with Node.js. So first, we need Node.js

So we will use a prebuilt Node.js image from Docker Hub. So that we can run the npm command.

Copy the line in your Dockerfile

FROM node:lts
Enter fullscreen mode Exit fullscreen mode

As we know, to serve our React app, we need a web server.
We already discussed that we will use Nginx for this.
Now let's install nginx.

Copy the line below in your Dockerfile

# Updates the Linux package manager and installs Nginx
RUN apt-get update && \
    apt-get install -y nginx
Enter fullscreen mode Exit fullscreen mode

Now let's set up WORKDIR inside our container. All subsequent commands (COPY, RUN, CMD) will be executed relative to this path.

Copy the line below in your Dockerfile

WORKDIR /frontend-app
Enter fullscreen mode Exit fullscreen mode

So let's recap what we've done. We have installed Node.js. Because we need it to build our project. Also, we installed nginx so that we can serve our React app.

Now to serve our frontend. We need to build our ReactJS project. To build it, we first need to install the project dependencies. Then we need to copy our project code inside the container. So that when we run the npm run build command. It successfully create build for our react.js project.

For installing dependencies we need package.json file.

Add this line to your Dockerfile

COPY package*.json ./
RUN npm ci
Enter fullscreen mode Exit fullscreen mode

This will copy package.json. Inside the workdir that we set up above. And then install dependencies.

What is RUN npm ci?

Runs a "clean install." This is faster and more reliable than npm install for automated environments (CI/CD) because it adheres strictly to the package-lock.json.

Via the above command, we installed our Project dependencies. Now we need to build our project. So, for building the project, we need to copy our frontend project code as well inside the container.

Add this line to your Dockerfile

COPY . .
Enter fullscreen mode Exit fullscreen mode

Copy the rest of your source code into the container. Except files which are defined inside the dockerignore file. We will talk about the dockerignore later.

Now, let's add a build command for building our project.

RUN npm run build
Enter fullscreen mode Exit fullscreen mode

Via this command, we build our project.

Now we need to set up our Nginx so that it serves our static build files.

First of all, we create a directory. Where will we store our static files? Right now, our bundle files are present inside the dist folder.

Then we copy those files from dist to the mywebsite folder.

Add these lines to your Dockerfile

# Ensure nginx directories exist
RUN mkdir -p /frontend-app/mywebsite

# Copy nginx config
COPY nginx.conf /etc/nginx/sites-available/default

# Move dist files
RUN cp -r ./dist/* /frontend-app/mywebsite/
Enter fullscreen mode Exit fullscreen mode

We did not talk about this line COPY nginx.conf /etc/nginx/sites-available/default

This line will overwrite the default Nginx configuration with your custom config file. This tells Nginx how to handle routing and where to look for static files.

We will talk about it more later.

We're done with our project setup. Now we need to expose the port as well. So that when we try to connect with our frontend, we can reach our web server, which is running on port 80.

EXPOSE 80
Enter fullscreen mode Exit fullscreen mode

This command tells users (and Docker) that the container listens on port 80. Here we are exposing the container port 80.

Now let's start our nginx server

CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

The above command is divided into two commands:

nginx: Starts the nginx server.
-g "daemon off;”: Runs Nginx in the foreground. If you don't do this, Nginx runs in the background, and the Docker container will immediately exit (stop) because it thinks its job is done.

That's it, we're done with our Dockerfile. Final version of your Dockerfile looks like this:

FROM node:lts

# Install nginx on Debian
RUN apt-get update && \
    apt-get install -y nginx

WORKDIR /frontend-app

COPY package*.json ./
RUN npm ci

COPY . .

RUN npm run build

# Ensure nginx directories exist
RUN mkdir -p /frontend-app/mywebsite

# Copy nginx config
COPY nginx.conf /etc/nginx/sites-available/default

# Move dist files
RUN cp -r ./dist/* /frontend-app/mywebsite/

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

6. Creating nginx configuration file: Configuring nginx to serve our static files

Prefer Video? Watch Here

When we write our Dockerfile. We encounter one line

COPY nginx.conf /etc/nginx/sites-available/default
Enter fullscreen mode Exit fullscreen mode

Here we are copying some nginx.conf file to some location. But currently, in our project, inside the frontend directory. We don't have any nginx.conf file. So what's the purpose of this file?

Let's talk about it

Nginx comes with a default configuration file. When a request reaches Nginx, it looks for a configuration file which matches the request. If it cannot find the conf file, it selects default.conf.

And default.conf comes with predefined rules. We want to override it with our custom configuration rules. So that niginx handle our request flawlessly.

Also, we want nginx to look into /frontend-app/mywebsite directory. For serving static file.

Create a file nginx.conf inside frontend directory.

And then copy and paste the content below into it

server {
    listen 80;          # Listen for IPv4
    listen [::]:80;     # Listen for IPv6
    server_name localhost;

    root /frontend-app/mywebsite;
    index index.html;


    location / {
        try_files $uri /index.html;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's break down each line step by step

First things first, the conf file had

server {
options
}
This denotes a server block. When any request reaches the web server, it will start looking for server_name on these server blocks. Once server_name matches, then that particular server block option is applied to the server.

Nginx is designed to always serve something rather than just crashing. If it can't find a server_name that matches the URL the user typed, it looks for a block labelled with the default_server flag.

Standard Default: Usually, this is a block that listens on Port 80 and points to a folder like /var/www/html or /usr/share/nginx/html.

The Result: The user will see the famous "Welcome to nginx!" landing page instead of your actual website.

I hope you understand the point. Now let's break down the above file step by step.

listen 80;
Tells Nginx to listen for incoming web traffic on port 80 using IPv4.

listen [::]:80;
Tells Nginx to listen for incoming web traffic on port 80 using IPv6.

server_name localhost;
Tells Nginx to apply these rules when the request comes to "localhost" (or your domain name in production).

root /frontend-app/mywebsite;
Sets the home base. It tells Nginx that all your static files (HTML, CSS, JS) are stored inside the /frontend-app/mywebsite folder.

index index.html;
Defines the default file. When a user visits your site without specifying a file, Nginx automatically serves them index.html.

The "Magic" for Single Page Apps (React):

location / {
try_files $uri /index.html; This line prevents 404 errors on refresh. It tells Nginx: "First, look for the specific file the user asked for. If you can't find it, don't show an errorβ€”just serve index.html and let the JavaScript app handle the routing."
}

$uri β€”> Requested File like if user type
http://localhost:8080/dashboard β€”> $uri is /dashboard.

Nginx checks: Does a file called /frontend-app/mywebsite/dashboard exist?
Result:
No (Because /dashboard is just a virtual route in your React app, not a real file on the disk)

Fallback: Nginx moves to the second option in your try_files line -> /index.html.

Outcome: The user gets index.html, and your JavaScript app loads and reads the URL to display the β€œDashboard” page content.

We are still not ready to build our image. Because if you try to build an image now. We will accidentally send our .env secrets or a heavy folder like node_modules. Because in our DockerFile we added this line COPY . ..

We need to restrict this behaviour somehow so that Docker will not pick up those files.

How can we do this?

Ans is the .dockerignore file.

What is .dockerignore?

The .dockerignore file is just like .gitignore, but for Docker. It tells Docker which files to exclude when sending your code to the container builder.

It speeds up the process of building images. It ignore heavy folder likes node_module, dist etc

It prevents sensitive files (like .env secrets or .git history) from accidentally ending up inside your public image.

Create a .dockerignore file inside the frontend directory

and copy the content

**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.next
**/.cache
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
**/build
**/dist
LICENSE
README.md
**/backend
/*.yaml
Enter fullscreen mode Exit fullscreen mode

7. Building Docker Image: Building Docker Image and Running it

Prefer Video? Watch Here

Now we are all set for building our Docker image

After saving all the files

Go to your terminal and go to the frontend directory by running the command

cd frontend
Enter fullscreen mode Exit fullscreen mode

Once you are in the frontend directory. Now, let's build a Docker image

Run the build command

docker build -t my-frontend-todo-app:v1 .
Enter fullscreen mode Exit fullscreen mode

Let's break down the above command

docker build

This is the core instruction. It tells the Docker engine to read a Dockerfile and "bake" it into a static image (a snapshot of your application).

-t my-frontend-todo-app:v1

-t (Tag): This flag lets you name your image.

my-frontend-todo-app: our image name

:v1 Version tag allows us to provide a version to our build. If we leave this, our Docker default uses the latest as the version.

. β€”> (The Dot) Most important part! It effectively says: "Look for the Dockerfile and the source code in the current folder I am in right now."

Once you run the above command and it build docker image successfully. Your terminal looks like this:

Success output of docker build command in terminal

You can also check the Docker image by going to Docker Desktop inside the image tab.

Docker desktop view image tab

The diagram illustrates what we have done so far.

High level view of docker flow from source code to docke image

We successfully created our frontend image. Now it's time to run it.

In the same terminal where you built the image, run the command below or open a new terminal and cd to frontend if you are not in the frontend directory.

And run the command

docker run -it -p 8080:80 --name my-todo-app-container my-frontend-todo-app:v1
Enter fullscreen mode Exit fullscreen mode

Once you run the above command, your terminal looks like this:

Docker interactive command

Let's break down the above command

docker run

The command to start a new container from an image.

-it

This runs the container in Interactive Mode. Instead of running silently in the background, it attaches the container to your current terminal window so you can see the logs (Nginx output) immediately.

-p 8080:80

(Port Mapping) This acts as a bridge between your computer and the container.

8080 β€”> The port on your laptop. You will access the site at http://localhost:8080

80 β€”> The port inside the container where Nginx is listening.

-name my-todo-app-container

Gives the container a custom name. Without this, Docker assigns a random name.

my-frontend-todo-app:v1

Tells Docker exactly which image and version tag to use as the blueprint for this container.

Note:
Right now, we are running the Docker container in interactive mode, which has hijacked our current terminal. If you don’t want that, run the Docker container in detach mode so that the Docker container runs in the background without hijacking our terminal.

The command will run the container in detach mode. Just replace -it with -d. You can play around with it more

docker run -d -p 8080:80 --name my-todo-app-container my-frontend-todo-app:v1
Enter fullscreen mode Exit fullscreen mode

Once the above command is successful, it starts showing in Docker Desktop. Go and check the container section in Docker Desktop

Docket Desktop With running container

Now go to the browser and go to the URL http://localhost:8080. Now you are able to see our to-do app.

our web app frontend

Hurray, our app is perfectly built and up and running.

See the image below for a high-level view. What’s going on

high level view of frontend on docker flow

Let's test out the whole flow

If your backend is not running. Go to the backend directory and spin up the backend server. By running

npm run dev
Enter fullscreen mode Exit fullscreen mode

Note: Make sure your MySQL container is up and running

Once our backend is up and running. Go to the browser where our frontend app is running and open the network panel. Or go to the URL http://localhost:8080/.

Open the network tab and in the filter select fetch/xhr

Now refresh this page. We are able to see undefined in the network panel. Click on it to open the details of the request.

Frontend with network panel

Network panel descriptive view

Ok, but why is undefined going in the request?

Let’s try to add a new todo and see what’s going to happen

Post request output

It is still giving me undefined in the URL. What’s going on

Our backend server is running on port 5500, but the frontend call goes to http://localhost:8080/undefined.

Why, because we messed up with the env variable.

Check your .env file in the frontend

It looks like below

env file view

This is the correct URL for our backend. But we do not pass our env variable to the frontend, that’s why it’s calling the wrong URL with undefined.

So, how can we pass our environment variable in a Docker image? In the next section, we will talk about it.

8. Managing Environment Variables: Configuring API URLs and secrets securely.

Prefer Video? Watch Here

Handling environment variables is crucial and requires extra precaution. So that we do not expose our secrets accidentally in public.

Let's understand how to handle env variables

There are two ways we can pass the env variable in our Docker image

  1. Passing env variables during build time using build args.
  2. Passing env variables at run-time by injecting them into the window object.

Let's discuss both of them one by one

1. Passing env variable during build time using build args:

In this approach, when we are building the image, we only need to pass the env variable using the build arguments into our image.
These variables become static. In order to change the env variable, we need to rebuild the image.

This approach we mostly avoid because it is not efficient. Because we need to rebuild the image every time if the environment variable changes. And building images is a costly or time-consuming process.

Ok, enough talk now, let’s try out

First things first, we need to add the build arg VITE_API_URL in our Docker file. Add it after the line
COPY . .

# Build arguments for environment variables
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
Enter fullscreen mode Exit fullscreen mode

Now our Dockerfile looks like


FROM node:lts

# Install nginx on Debian
RUN apt-get update && \
    apt-get install -y nginx

WORKDIR /frontend-app

COPY package*.json ./
RUN npm ci

COPY . .

# Build arguments for environment variables
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL

RUN npm run build

# Ensure nginx directories exist
RUN mkdir -p /frontend-app/mywebsite

# Copy nginx config
COPY nginx.conf /etc/nginx/sites-available/default

# Move dist files
RUN cp -r ./dist/* /frontend-app/mywebsite/

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

Now build a new image by passing the build args as our env variables. In our case, it's a backend URL. Go to the terminal where we ran our Docker container. Stop it by pressing Ctrl + c. It stops the running container.

Then run the docker build command by passing the --build-arg flag with the build arg VITE_API_URL, with our backend URL.

Like --build-arg VITE_API_URL=http://localhost:5500/api/todos

Copy Api backend URL from the frontend .env file. And then pass that in
docker build command

docker build --build-arg VITE_API_URL=http://localhost:5500/api/todos -t my-frontend-todo-app-with-env:v1 .
Enter fullscreen mode Exit fullscreen mode

Once it's built successfully, it looks something like this

Output from terminal after success full build args command

Now run it via the docker run command

docker run -d -p 8080:80 --name my-todo-container-with-env my-frontend-todo-app-with-env:v1
Enter fullscreen mode Exit fullscreen mode

In the command above, we ran it in detach mode, so it will not hijack our terminal. It will start the container in the background.

If the above command is successful, you will see something like this in the terminal

Terminal o/p after running docker run command

You can also verify from the Docker Desktop

Now go to http://localhost:8080 or refresh the page if you already have it open in the browser

Woo, we can see two seeded todos from the database, and we are now hitting the correct URL.

Browser o/p of successfully running frontend app

We're done with this approach. Let’s see the second approach now

2. Passing env variables at run-time by injecting them into the window object

So the idea is that somehow we will provide an env variable to our frontend at runtime so that we don’t need to build images every time.

So How we do this?

Our frontend is running on a browser. They cannot access the Docker container's environment variables (like process.env) at runtime.
So when the browser requests index.html from the server. We inject one JS script into our index.html. That script will inject our env variable into the window object.

Note:

But this approach only works if your environment does not contain any sensitive data. In general, we avoid putting sensitive data like api key, password, etc. in the frontend. Because with this approach, anyone can see your env variable in the console by accessing the window object.

Let’s implement this as well

Comment out those lines that we added previously for build args.

# Build arguments for environment variables
# ARG VITE_API_URL
# ENV VITE_API_URL=$VITE_API_URL
Enter fullscreen mode Exit fullscreen mode

comment code for build args

Now, inside the frontend directory, create a file nginx-entrypoint.sh

And copy the snapshot below and paste it into nginx-entrypoint.sh

#!/bin/sh

# Start the generated config file
CONFIG_PATH="/frontend-app/mywebsite/env-config.js"
echo "window.env = {" > $CONFIG_PATH

# Find all environment variables starting with "VITE_" and add them to the file
for var in $(env | grep "VITE_"); do
  name=$(echo "$var" | cut -d "=" -f 1)
  value=$(echo "$var" | cut -d "=" -f 2-)
  echo "  $name: \"$value\"," >> $CONFIG_PATH
done

# End the config file
echo "};" >> $CONFIG_PATH
echo "Environment variable injection completed."
exec nginx -g 'daemon off;'
Enter fullscreen mode Exit fullscreen mode

nginx-entrypoint.sh

This script runs immediately before Nginx starts. It takes the environment variables from Docker and writes them into a real JavaScript file that the browser can read.

Line-by-Line Breakdown

  1. Create the File (echo "window.env..."). It creates a new JavaScript file called env-config.js inside the /frontend-app/mywebsite and starts defining a global object: window.env = {.

  2. The Loop (for var in...) It looks through all the system environment variables currently set in the container.
    • grep "VITE_": It filters the list to only grab variables that start with VITE_ (security measure to avoid exposing secrets like internal keys).

  3. The Injection (echo " $name: \"$value\","). It extracts the Name and Value of the variable and writes them into the JS file as a key-value pair.
    • Result inside file: VITE_API_URL: https://my-api.com,

  4. Close & Launch (exec nginx...) It closes the JS object (};) and finally starts the Nginx server to serve your website.

Final Result: Our frontend code can now access variables via window.env.VITE_API_URL.

Once the above file is saved. Now we want to run this script before nginx starts so that we can inject the env variable at runtime. To do that

Go to Dockerfile and add the lines after the RUN mkdir -p /frontend-app/mywebsite command

COPY nginx-entrypoint.sh /frontend-app/nginx-entrypoint.sh
RUN sed -i 's/\r$//' /frontend-app/nginx-entrypoint.sh
RUN chmod +x /frontend-app/nginx-entrypoint.sh
Enter fullscreen mode Exit fullscreen mode

Here we are copying nginx-entrypoint.sh into Docker, converting any Windows-style line endings to Linux format to prevent execution errors, and giving it executable permission so that Docker can run this script.

After adding the above lines, our Docker file looks like this:

Docker File after adding nginx-entrypoint

Now, comment out this line

# CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

And after the above line, add

ENTRYPOINT ["/frontend-app/nginx-entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

This command tells Docker: "No matter what else happens, run this script first when the container starts."

It guarantees your environment variables are injected every single time the container launches.

Finally, our Dockerfile will look like this

FROM node:lts

# Updates the Linux package manager and installs Nginx
RUN apt-get update && \
    apt-get install -y nginx

WORKDIR /frontend-app

COPY package*.json ./
RUN npm ci

COPY . .    

# Build arguments for environment variables
# ARG VITE_API_URL
# ENV VITE_API_URL=$VITE_API_URL

RUN npm run build

# Ensure nginx directories exist
RUN mkdir -p /frontend-app/mywebsite
COPY nginx-entrypoint.sh /frontend-app/nginx-entrypoint.sh
RUN sed -i 's/\r$//' /frontend-app/nginx-entrypoint.sh
RUN chmod +x /frontend-app/nginx-entrypoint.sh

# Copy nginx config
COPY nginx.conf /etc/nginx/sites-available/default


# Move dist files
RUN cp -r ./dist/* /frontend-app/mywebsite/

EXPOSE 80
# CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT ["/frontend-app/nginx-entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

We are almost done with this approach; only a small change is required.

As we know, this script will generate an env-config.js file. We also need to add this script to our index.html, then only the browser downloads it, and then only the JavaScript script executes and adds our env variables into the window object.

Go to index.html of the frontend directory and add the line below inside the <head> tag.

<script async src="/env-config.js"></script>
Enter fullscreen mode Exit fullscreen mode

index.html

index html file after adding env script in head

Now that we're done with this setup, let’s build a new image. Make sure you are in the frontend directory.

Run the build command in the terminal for building the image

docker build -t my-frontend-todo-app-with-runtime-env:v1 .
Enter fullscreen mode Exit fullscreen mode

It’s build our image if everything is fine.

Now, how to pass the env variable so that our nginx-entrypoint.sh reads our env variable.

We can pass an env variable or a whole env file via the Docker run command. To run and pass environment variables into a Docker container, run the command below.

Note:

Before running the command. Make sure port 8080 is free. If your frontend container is running on port 8080. Please first stop it.
Via Docker Desktop.

Go to Docker Desktop and click on the action button

Docker desktop image for stopping the container

docker run -d -p 8080:80 --env-file ./.env --name my-todo-container-with-runtime-env my-frontend-todo-app-with-runtime-env:v1
Enter fullscreen mode Exit fullscreen mode

Here, the --env-file flag is used to pass the whole .env file.

Once your above command is successful, our container starts successfully

terminal o/p after running docker run command with runtime env

Now our container is running successfully. Go to the browser and check http://localhost:8080

HurrayπŸ‘, we can see our todos

Frontend After configuring the runtime env

We've done it. You can check it in the window object by going to the browser devtools console panel and typing

window.env
Enter fullscreen mode Exit fullscreen mode

You can see VITE_API_URL env variables in the devtool console panel.

Console output for env variable in the window object

We successfully passed our ENV_variable at runtime. I mostly prefer this approach when my environment variable contains no sensitive information.

So we're done with containerizing our frontend app. Now, let’s look at how to optimize the size of our Docker image. And make it production-ready

In the next section, we will discuss it.

9. Docker File optimization: Intro to Docker Image Optimization.

Prefer Video? Watch Here

Let’s first check the size of our images

Run the below command to see the list of images. Remember, we need to focus on the image my-frontend-todo-app-with-runtime-env:v1

docker images
Enter fullscreen mode Exit fullscreen mode

List of docker images

See the size of our image, it is 1.28 GB. And see also the MySQL DB image size 777mb. Our image size is greater than MySQL.

Note: These image sizes may vary from system to system. So don't worry about it. Just understand the intent.

This is not good; we need to reduce the size of the image because in production, we can’t use very large images. Also, when Docker downloads this image from the registry, it takes a lot of time.

So, how can we reduce image size?

Right now, we are using the node:lts image for Node.js. Which is a standard image and not lightweight.

In Docker for production usage, we mostly prefer images like Alpine, slim and distroless images. These images are lightweight and recommended for use.

Note:
If you are building any banking application where security is a concern, then go with distroless images. These images do not contain any bash, package manager, or Hardest to hack because a hacker is unable to execute any command on it. But also the hardest to debug.

For our use case, we will use an Alpine image of Node.js.

We need to make some changes to our Dockerfile. So that we can use an Alpine image.

Change

FROM node:lts
Enter fullscreen mode Exit fullscreen mode

To

FROM node:lts-alpine3.22
Enter fullscreen mode Exit fullscreen mode

Replace

RUN apt-get update && \
     apt-get install -y nginx
Enter fullscreen mode Exit fullscreen mode

with

RUN apk add --no-cache nginx
Enter fullscreen mode Exit fullscreen mode

Replace

COPY nginx.conf /etc/nginx/sites-available/default
Enter fullscreen mode Exit fullscreen mode

With

COPY nginx.conf /etc/nginx/http.d/default.conf
Enter fullscreen mode Exit fullscreen mode

After changes, our Dockerfile looks like below

FROM node:lts-alpine3.22

RUN apk add --no-cache nginx    

WORKDIR /frontend-app

COPY package*.json ./
RUN npm ci

COPY . .    

# Build arguments for environment variables
# ARG VITE_API_URL
# ENV VITE_API_URL=$VITE_API_URL

RUN npm run build

# Ensure nginx directories exist
RUN mkdir -p /frontend-app/mywebsite
COPY nginx-entrypoint.sh /frontend-app/nginx-entrypoint.sh
RUN sed -i 's/\r$//' /frontend-app/nginx-entrypoint.sh
RUN chmod +x /frontend-app/nginx-entrypoint.sh

# Copy nginx config
COPY nginx.conf /etc/nginx/http.d/default.conf


# Move dist files
RUN cp -r ./dist/* /frontend-app/mywebsite/

EXPOSE 80
# CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT ["/frontend-app/nginx-entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

Now let’s build this image and run it. But before doing that, we need to stop the frontend container that we started previously.

Go to Docker Desktop and click on the action button

Docker desktop for stopping already running container

Run the build command for building the image. Run inside the frontend directory

docker build -t my-frontend-todo-optimize-app:v1 . 
Enter fullscreen mode Exit fullscreen mode

To run a container, run the command below in the terminal

docker run -d -p 8080:80 --env-file ./.env --name my-frontend-todo-optimize-app my-frontend-todo-optimize-app:v1
Enter fullscreen mode Exit fullscreen mode

Now, once it runs successfully, go to the browser and check http://localhost:8080

Todo app after changing node js image to alpine

It’s working fine, and it's able to access our backend as well.

Note:
If you face any issue on the backend, like the todos api getting 500, then stop and then again restart the backend server.

Now go to the terminal and run the docker images command, and now check the size of the container.

docker images command output

See that from 1 GB to 281 MB we drastically reduce the size.

Can we optimize our image further?

Is this image is production ready?

Do we build images like this in production? Or is there any other way?

We will talk about it in the next section.

10. Multi-Stage Docker file: The professional way to shrink image size.

Prefer Video? Watch Here

For production, a multi-stage Docker file is recommended.

So the Dockerfile we built so far is a single-stage Dockerfile. There is only one stage.

The drawback of a single-stage Docker file is that it attempts to do everythingβ€”downloading source code, installing dependencies, building the app, and running itβ€”inside the same environment.

This increases the size of the images. Also, it exposes the attack surface for hackers because it contains more tools that will give hackers more chances to hack it.

But if we use a multi-stage Docker build, we get rid of these problems

So In multi stage docker file. We build our image in multiple stages. Like the build stage, final stage or prod stage.

In the build stage

  1. We copy our source code and package.json, package-lock.json files.
  2. Then we install all the dependencies.
  3. And finally, we build the code.

In Final stage or Prod Stage

  1. We install the nginx image
  2. Copy the dist folder from the build stage and put it inside the web server directory.

Because in Prod we only need a web server and our build folder, that’s it.

And in a multi-stage Dockerfile, the final image only had these two things.

Enough talk, let’s write our multi-stage Docker file

First things first, comment out the older content of the Docker file. Once you comment out the Docker file, you will be able to see some errors in VS Code. But don't worry about it.

Then copy the content below after the commented content

FROM node:lts-alpine3.22 AS build
WORKDIR /frontend-app
RUN --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    --mount=type=cache,target=/root/.npm \
    npm ci

# Copy the rest of the source files into the image.

COPY . .

# Run the build script.

RUN npm run build

FROM nginx:stable-alpine-slim AS final

COPY nginx-entrypoint.sh /frontend-app/nginx-entrypoint.sh
RUN sed -i 's/\r$//' /frontend-app/nginx-entrypoint.sh
RUN chmod +x /frontend-app/nginx-entrypoint.sh

# Copy the production dependencies from the build stage
COPY --from=build /frontend-app/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /frontend-app/dist /frontend-app/mywebsite


# Expose the port that the application listens on.
EXPOSE 80
ENTRYPOINT ["/frontend-app/nginx-entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

Line-by-line explanation

So here we define two stages, one is build and another is final

Build Stage:

We set up node js environment for building our application.

FROM node:20-alpine3.22 AS build

Think of AS build like giving a name tag to a specific step in your setup. It simply lets you tell Docker later on, 'Hey, remember that step we named 'build'? Go grab the files from there.

The above line tells Docker to download a lightweight version of Linux ("Alpine") that has Node.js LTS version installed.

WORKDIR /frontend-app

Creates a folder inside the container at /frontend-app and sets it as the default location for all future commands.

RUN --mount=type=bind,source=package.json,target=package.json

This command looks different. Usually, we do COPY package.json . This physically duplicates the file into a layer of our Docker image.

But --mount=type=bind,source=package.json,target=package.json. In this way, we are telling to Docker Don't copy the file. Just let me 'borrow' package.json from my computer (source) and make it visible inside the container at package.json (target) just for the duration of this command.

It keeps the image layer cleaner. As soon as the command finishes, the file is technically no longer "in" that layer, because it was never copied, only mounted.

Note: The second line does the same thing for the package-lock.json file.

--mount=type=cache,target=/root/.npm

Problem:

Normally, Docker containers start fresh. When you run npm install, it downloads megabytes of files from the internet. If you rebuild the image, it forgets those downloads and does it all over again.

This command tells Docker to create a special storage bucket (cache) on your host machine (your computer) and mount it to /root/.npm inside the container (which is where npm stores its cache).

So now npm downloads libraries and saves them to /root/.npm. As cache, and when we try to rebuild the image. It just reaches out to our host machine( our laptop) storage and grabs the files instead of downloading them from the internet again.

This speeds up the process of installing dependencies. Also not store package.json or package-lock.json files in our image because we don’t need these files in our final image as well.

npm ci

Installs your project dependencies.

COPY . .

Copies all the files from your laptop (current directory) into the container's current folder (/frontend-app)

RUN npm run build

This usually creates a dist folder containing the final website files.

Final Stage:

This section throws away the heavy Node.js tools and starts fresh with a tiny web server.

FROM nginx:stable-alpine-slim AS final

We use a "slim" version of Nginx. It doesn't have Node.js; it only knows how to serve web files.

COPY nginx-entrypoint.sh /frontend-app/nginx-entrypoint.sh

Copies a custom script file from your laptop to the root of the container.

RUN sed -i 's/\r$//' /frontend-app/nginx-entrypoint.sh

Removes hidden Windows line endings from the script so it doesn't crash inside the Linux container.

RUN chmod +x /frontend-app/nginx-entrypoint.sh

Changes the permissions of that script to make it executable (so it can be run as a program).

COPY --from=build /frontend-app/nginx.conf /etc/nginx/conf.d/default.conf

It reaches back into the build stage to grab our custom configuration file and places it into the final Nginx folder to overwrite the server's default settings.

COPY --from=build /frontend-app/dist /frontend-app/mywebsite

Copies the compiled website from the dist folder from the "build" stage to a folder named /frontend-app/mywebsite in the final stage.

EXPOSE 80

This container listens on port 80

ENTRYPOINT ["/frontend-app/nginx-entrypoint.sh"]

Specifies the script that must run every time the container starts up.

If you didn't use multi-stage builds, your final image would include all of Node.js, your source code, and thousands of dev dependencies, making it heavier. By using this method, your final image only contains Nginx and your static files, this make your final build lighter.

Now let’s build an image and then run it to check if it’s working fine or not.

In the terminal, go to your frontend directory and run the build command

docker build -t my-frontend-todo-app-multistage:v1 . 
Enter fullscreen mode Exit fullscreen mode

It's build successfully

Build output

We build it successfully. Now let’s run it.

Note: Before running the container. Please stop the already running container on port 8080.

docker run -d --env-file ./.env -p 8080:80 --name todo-app-multistage-container my-frontend-todo-app-multistage:v1
Enter fullscreen mode Exit fullscreen mode

Our container is running now

docker desktop running container

Now go to the browser and open the URL http://localhost:8080

Frontend after multistage dockerfile setup

Hurray, it’s working fine. Make sure your backend server is running.

Now, let’s check the size of our image. Go to the terminal and run

docker images
Enter fullscreen mode Exit fullscreen mode

Terminal output after docker images

See the image size now, from 281 MB to 13.4 MB. Now our image is ready for prod usage.

We're done with our image optimization. Is it ready for production environments? Not yet, we need to secure it as well. We will discuss it in the upcoming section.

11. Securing Frontend Docker Container: Restricting folder permissions and user access.

Right now, our container is running as a root user. This is not recommended in a production environment. If an attacker manages to get the access of our server. Then they can take over the entire server. Which is soo scary.

To completely secure our container, we need to run it using the least privileged user. This ensures that even if an attacker gains access to the container, they cannot execute malicious code or alter server permissions. They are effectively locked down and stripped of the authority to compromise the system.

So, before we proceed, we need to understand how Linux file permissions work.

1. The Three Security Scopes (The "Who")

Prefer Video? Watch Here

Every file and directory in Linux evaluates access requests sequentially against three distinct user scopes:

  • Owner (u): The system administrator who created the resource (usually root in a container).
  • Group (g): The specific service account or application tier assigned to the resource (e.g., sudo group).
  • Others (o): Unauthenticated entities or unrelated system users. All the other users.

When you issue a command like chmod 750, you are passing a three-digit configuration payload. The first digit applies to the Owner, the second to the Group, and the third to Others.

2. The Binary Breakdown: Read, Write, and Execute

Linux permissions do not use complex configuration files. They are based entirely on three simple actions, mapped to a 3-bit binary system where 1 is ON (allowed) and 0 is OFF (denied).

Here are the only three actions a user can take:

  • Read: You can view the text inside a file, or see the list of files inside a folder.
  • Write: You can edit, modify, or delete a file.
  • Execute: You can run a file as a script or program. (Crucial gotcha: For **folders, Execute simply means the ability to open or "enter" the folder).

Each of these three actions is assigned to a specific slot in a 3-bit binary number. When you convert that binary sequence into standard decimal numbers, you get the magic numbers used in chmod:

  • Read is the first slot (1 0 0 in binary), which equals 4.
  • Write is the middle slot (0 1 0 in binary), which equals 2.
  • Execute is the last slot (0 0 1 in binary), which equals 1.

To give a user a combination of permissions, you simply turn on the binary bits you want, which adds the numbers together.

For example: If you want a user to be able to Read and Execute, but not Write, the binary is 1 0 1. If you add the standard numbers together (4 + 0 + 1), you get 5. If you want them to have all three powers (1 1 1), it is 4 + 2 + 1 = 7.

3. The Practical Implementation (chmod 750)

Prefer Video? Watch Here

We achieve this with the matrix 750:

  • Owner gets 7 (4 + 2 + 1): The binary flag is 111. Root has Read, Write, and Execute authority.
  • Group gets 5 (4 + 0 + 1): The binary flag is 101. Group users get Read and Execute authority, but Write (0) is strictly denied.
  • Others get 0: The binary flag is 000. A total block for other users.

I hope you get some understanding of Linux permissions from above. Let's see it in action

Right now, our Dockerfile runs as the root user.

To check it. Go inside the Docker container by using the docker exec command

For Mac

docker exec -it todo-app-multistage-container sh
Enter fullscreen mode Exit fullscreen mode

For Windows, run the command

winpty docker exec -it todo-app-multistage-container sh
Enter fullscreen mode Exit fullscreen mode

Once you're inside the container, run the whoami command

whoami
Enter fullscreen mode Exit fullscreen mode

You will see the root

Like this

Docker whoami command output

Now go inside the frontend-app directory.

cd /frontend-app
Enter fullscreen mode Exit fullscreen mode

Check the list of files in /frontend-app by running the ls command.

ls
Enter fullscreen mode Exit fullscreen mode

You will see a list of files as shown in the image below

list of files inside the front-app

Now, let's try to simulate the same behaviour. If our server gets hacked by someone. What can an attacker do if he can get the root access?

He might delete the mywebsite folder. Then our website crashed immediately. Because it contains all the static files.

Let's see what happened if we delete the mywebsite folder.

Inside the frontend-app, run the following command

rm -rf mywebsite/
Enter fullscreen mode Exit fullscreen mode

This command deletes the whole mywebsite folder.

Now, if you run the ls command, you will not be able to see the mywebsite folder.

ls
Enter fullscreen mode Exit fullscreen mode

output after removing mywebsite

Now go to our frontend URL http://localhost:8080.

You will see a 500 internal server error.

frontend failure

See our website broke now. So, for mitigating this issue. We
need to run our container with the least privileged user. So no one can delete or interrupt our resources on the server.

How can we do it πŸ€”?

So, to lock down our Dockerfile. Need to do the following tasks:

  1. Creating a group, e.g. frontend_group.
  2. Creating a user, e.g. frontend_user, and adding it to the frontend_group.
  3. Creating the necessary folders required for running nginx properly.
  4. Then we need to set the owner for the folders. Which required protection.
  5. Then we need to lock down our files and folders. And give the least privilege to the groups and users.

Now, let's do each step one by one

1. Creating a group, e.g. frontend_group

In your Docker file, after the last COPY command. Add the following command for creating the group

RUN addgroup -S frontend_group
Enter fullscreen mode Exit fullscreen mode

Breakdown of the above command

RUN addgroup -S frontend_group

addgroup: This tells Linux to create a brand new group.

-S: This stands for "System." It tells Linux to make this a system group rather than a normal user group. System groups don't have passwords and are specifically designed for running background services (like Nginx or Node), which makes them much more secure.

2. Creating a user, e.g. frontend_user, and adding it to the frontend_group

After that, to create a user and add it to the group, add the following command after the above command

RUN adduser -S frontend_user -G frontend_group
Enter fullscreen mode Exit fullscreen mode

Pro-Tip: The One-Liner Optimization (&&)

While writing those commands on two separate lines makes them easy to read, you will almost always see them combined in professional Dockerfiles like this:

RUN addgroup -S frontend_group && adduser -S frontend_user -G frontend_group
Enter fullscreen mode Exit fullscreen mode

Why do we do this? Docker Layers.

Every time you write the word RUN in a Dockerfile, Docker creates a brand new "layer" (a snapshot) of your system.

  • Two RUN commands = Two layers added to your image.
  • One RUN command with && = One single layer.

By chaining related commands together with &&, you tell Docker to execute both actions simultaneously inside the exact same layer. This reduces your final image size, speeds up build times, and is the golden standard for production Dockerfiles!

3. Creating the folder required to run nginx properly.

We need to create the /run/nginx folder. Because
When Nginx boots up, the operating system assigns it a unique ID number. Nginx is hardcoded to instantly write this number into a file called nginx.pid.

By default, it tries to save this file in a locked, root-only system folder. Because we are securing our app by running as a non-root user, Nginx will instantly crash with a "Permission Denied" error. We pre-create the /run/nginx folder to give our app a dedicated, writeable sandbox just for this one file.

Add the mkdir command below after the above line

RUN mkdir -p /run/nginx
Enter fullscreen mode Exit fullscreen mode

4. Setting the owners for the folders which required protection

For securing our frontend app. We need to provide protection mostly to the frontend-app and some nginx folders.

Then add the Run command

RUN chown -R root:frontend_group /frontend-app /etc/nginx /run/nginx /var/cache/nginx /var/log/nginx
Enter fullscreen mode Exit fullscreen mode
  • RUN: Tells Docker to execute this command while building the image.
  • chown (Change Owner): This is the Linux command used to change who owns a file or folder.
  • -R (Recursive): Just like in our chmod commands, this tells Linux to dive into the folders and apply this ownership to every single file and sub-folder hidden inside them.
  • root:frontend_group: This is the core logic. You are explicitly stating two things:
  • The Owner is root: The system administrator owns the files.
  • The Group is frontend_group: You are assigning these files to your application's service group.

Why do it this way? By making the root owner, you permanently prevent the frontend_user from ever running a chmod command to change permissions later.

The Folders (Why these specific five?)

When you run Nginx as a standard, non-root user (like frontend_user), you have to manually grant it access to the specific folders it relies on to function. If Nginx cannot read or write to these native Linux folders, the container will instantly crash with a "Permission Denied" error on startup.

Here is exactly what Nginx is doing in each of those directories:

  • /frontend-app

    • What it is: Your custom directory containing your compiled React/Vite build (the dist folder).
    • Why it needs it: Nginx needs permission to open this folder to serve your HTML, CSS, and JS files to the user's browser.
  • /etc/nginx

    • What it is: The default Linux directory for Nginx configuration files (like nginx.conf and your custom server blocks).
    • Why it needs it: Nginx has to read these rules to know how to route traffic, handle API proxies, and serve your application.
  • /run/nginx

    • What it is: A temporary system folder used to store the PID (Process ID) file.
    • Why it needs it: When Nginx starts, Linux assigns it a unique ID number and writes that number to a file (e.g., nginx.pid). If Nginx cannot create this file, it refuses to start.
  • /var/cache/nginx

    • What it is: The storage location for temporary files and cached data.
    • Why it needs it: When Nginx processes large client requests, handles fastCGI, or acts as a proxy, it buffers that data into temporary files. It needs strict Write access here to function efficiently.
  • /var/log/nginx

    • What it is: The official directory for access.log (who visited your site) and error.log (what broke).
    • Why it needs it: Nginx constantly streams data to these files while running. If it cannot write to the log folder, it will crash.

As we created a directory /run/nginx. For nginx to write its PID (Process ID).

By default, Nginx is hardcoded to write this file into a strict, root-only system folder. Because we are forcing Nginx to run as our non-root frontend_user, it will instantly crash with a "Permission Denied" error when it tries to write that PID file.

To fix this, we use a single command to rewrite the Nginx configuration file and point the PID to the /run/nginx folder we explicitly unlocked in the previous step.

# Update the PID path in the nginx config
 RUN sed -i 's|.*nginx.pid;|pid /run/nginx/nginx.pid;|g' /etc/nginx/nginx.conf
Enter fullscreen mode Exit fullscreen mode

This line changes the nginx internal configuration command. It changes the default path from nginx.pid to /run/nginx/nginx.pid.

5. Locking down our files and folders. And give the least privilege to the groups and users.

We have already seen the five folders above. We need to lock some files and folders among them.

For our two core directoriesβ€”/frontend-app (our code) and /etc/nginx (our config)β€”we give root full control. However, we restrict our frontend_group to only Read and Execute permissions. This allows Nginx to open the folders and serve the website perfectly, but physically prevents it from modifying, overwriting, or deleting any core files.

For locking down our core folder, use the chmod command

RUN chmod -R 750 /frontend-app /etc/nginx
Enter fullscreen mode Exit fullscreen mode

For our main application code, we used 750 to strictly block write access. However, Nginx physically must be able to write to its cache, logs, and PID directories to function.

We unlock these specific, isolated sandbox folders using the 770 lock code.

# Grant Write access to the isolated Nginx runtime folders
RUN chmod -R 770 /run/nginx /var/cache/nginx /var/log/nginx
Enter fullscreen mode Exit fullscreen mode
  • -R (Recursive): Apply this permission to the folders and everything inside them.
  • 770 (The Access Matrix):
    • Owner gets 7 (4 + 2 + 1): root retains full control.
    • Group gets 7 (4 + 2 + 1): Our frontend_group (Nginx) is granted Read, Write, and Execute powers. This is the crucial difference. We are explicitly allowing Nginx to generate and delete files inside these sandboxed directories.
    • Others get 0: Unauthenticated users are completely blocked.

By specifically targeting only these runtime directories with 770, we give Nginx exactly the Write permissions it needs to survive, without exposing our core application code to dangerous modifications!

That's it. Now let's switch from root to frontend_user.

Add the below command just before the expose command

USER frontend_user
Enter fullscreen mode Exit fullscreen mode

After all the above changes, your Dockerfile looks like

FROM node:lts-alpine3.22 AS build
WORKDIR /frontend-app
RUN --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    --mount=type=cache,target=/root/.npm \
    npm ci

# Copy the rest of the source files into the image.

COPY . .

# Run the build script.

RUN npm run build

FROM nginx:stable-alpine-slim AS final


COPY nginx-entrypoint.sh /frontend-app/nginx-entrypoint.sh
RUN sed -i 's/\r$//' /frontend-app/nginx-entrypoint.sh && \
chmod +x /frontend-app/nginx-entrypoint.sh

# Copy the production dependencies from the build stage
COPY --from=build /frontend-app/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /frontend-app/dist /frontend-app/mywebsite
RUN addgroup -S frontend_group && adduser -S frontend_user -G frontend_group
RUN mkdir -p /run/nginx
# # 1. Set root as the owner and frontend_group as the group for ALL folders.
# # Because root owns them, frontend_user is permanently blocked from using chmod.
RUN chown -R root:frontend_group /frontend-app /etc/nginx /run/nginx /var/cache/nginx /var/log/nginx
# # 2. Update the PID path in the nginx config
RUN sed -i 's|.*nginx.pid;|pid /run/nginx/nginx.pid;|g' /etc/nginx/nginx.conf
# # 3. Lock down the app and config folders to Read/Execute (750).
# # Owner (root): Read/write/Execute (7)
# # Group (frontend_group): Read/Execute (5)
# # Others: No Access (0)
RUN chmod -R 750 /frontend-app /etc/nginx
# # 4. Grant Write access to the specific runtime folders Nginx needs to function and env creation folder (770).
# # Owner (root): Read/Write/Execute (7)
# # Group (frontend_group): Read/Write/Execute (7)
# # Others: No Access (0)
RUN chmod -R 770 /run/nginx /var/cache/nginx /var/log/nginx
USER frontend_user

# Expose the port that the application listens on.
EXPOSE 80
ENTRYPOINT ["/frontend-app/nginx-entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

Now, let's build our Docker image and then run it

For building run inside the frontend directory

docker build -t my-frontend-secure-todo-multistage:v1 .
Enter fullscreen mode Exit fullscreen mode

secure build

Now, let's run our container. But before that, please delete your older frontend container that we created previously.

Then run

docker run -it --env-file ./.env -p 8080:80 --name todo-app-multistage-secure-container my-frontend-secure-todo-multistage:v1
Enter fullscreen mode Exit fullscreen mode

After running the above command, it runs successfully

output of running container

Now go to our frontend app and refresh it, or open the URL
http://localhost:8080

frontend app on browser with no todos

We are not able to see any todos in our app. Check if your backend server is still running. In my case, it's running. Also, our frontend again starts calling the undefined URL. This means we again messed up with env variables. But where πŸ€”?

Check the nginx logs in the terminal where you run the docker run command. If you check the starting logs. You will see some Permission Denied errors.

permission denied screen

What's the issue here?

If you remember, for injecting the runtime environment. We are creating a /env-config.js. On the startup of our container inside the directory /frontend-app/mywebsite.

So when we start our container with frontend_user. It had only read and execute permission for the directory /frontend-app/mywebsite. But here we are trying to create a new file inside the /frontend-app/mywebsite. Due to a lack of write permission, the creation of the /env-config.js file failed. And in the terminal, it's showing a permission denied error. How can we fix it?

I still don't want to give write permission to frontend_user for /frontend-app/mywebsite directory. Otherwise, we again get into trouble.

But I can create one directory inside the /frontend-app/mywebsite. Specially for creating the/env-config.js file. And permit write permission only for that particular directory instead of the whole /frontend-app/mywebsite directory.

So we create a /env directory inside the /frontend-app/mywebsite directory. And then give write permission to our frontend_user.

For creating /env, add the mkdir command. Just after this line RUN mkdir -p /run/nginx

RUN mkdir -p /frontend-app/mywebsite/env
Enter fullscreen mode Exit fullscreen mode

Now, to give write permission, just add the above directory at the end of the command.

Add /frontend-app/mywebsite/env at the end of the chmod command like this

RUN chmod -R 770 /run/nginx /var/cache/nginx /var/log/nginx /frontend-app/mywebsite/env
Enter fullscreen mode Exit fullscreen mode

There is one more problem we need to address

The Port 80 Problem: Privileged vs. Unprivileged Ports

If you try to run your secure, non-root Nginx container on standard HTTP Port 80, it will immediately crash. But why?

In Linux, network ports are divided into two strict categories:

  • Privileged Ports (0 to 1023): These include core internet protocols like HTTP (80), HTTPS (443), and SSH (22).
  • Unprivileged Ports (1024 to 65535): These are open for general application use.

Historically, Linux restricted ports under 1024 as a security measure. It guaranteed users that if a website was running on Port 80, it was officially set up by the system administrator (root), not a random user running a rogue script.

Because we intentionally stripped away our administrative powers by using USER frontend_user, Linux mathematically prevents our application from binding to Port 80.

The Solution: Instead of fighting Linux security, we follow the industry standard: we tell Nginx to run on an unprivileged port, like 8080.

So, in your Dockerfile, change EXPOSE 80 to EXPOSE 8080.

Finally, after all changes, your Dockerfile looks like

# FROM node:lts-alpine3.22

# # Install nginx on Apline Linux
# RUN apk add --no-cache nginx

# WORKDIR /frontend-app

# COPY package*.json ./
# RUN npm ci

# COPY . .

# # Build arguments for environment variables
# # ARG VITE_API_URL
# # ENV VITE_API_URL=$VITE_API_URL

# RUN npm run build

# # Ensure nginx directories exist
# RUN mkdir -p /frontend-app/mywebsite
# COPY nginx-entrypoint.sh /frontend-app/nginx-entrypoint.sh
# RUN sed -i 's/\r$//' /frontend-app/nginx-entrypoint.sh
# RUN chmod +x /frontend-app/nginx-entrypoint.sh

# # Copy nginx config

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


# # Move dist files
# RUN cp -r ./dist/* /frontend-app/mywebsite/

# EXPOSE 80
# # CMD ["nginx", "-g", "daemon off;"]
# ENTRYPOINT ["/frontend-app/nginx-entrypoint.sh"]


FROM node:lts-alpine3.22 AS build
WORKDIR /frontend-app
RUN --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    --mount=type=cache,target=/root/.npm \
    npm ci

# Copy the rest of the source files into the image.

COPY . .

# Run the build script.

RUN npm run build

FROM nginx:stable-alpine-slim AS final


COPY nginx-entrypoint.sh /frontend-app/nginx-entrypoint.sh
RUN sed -i 's/\r$//' /frontend-app/nginx-entrypoint.sh && \
chmod +x /frontend-app/nginx-entrypoint.sh

# Copy the production dependencies from the build stage
COPY --from=build /frontend-app/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /frontend-app/dist /frontend-app/mywebsite
RUN addgroup -S frontend_group && adduser -S frontend_user -G frontend_group
RUN mkdir -p /run/nginx
RUN mkdir -p /frontend-app/mywebsite/env
# 1. Set root as the owner and frontend_group as the group for ALL folders.
# Because root owns them, frontend_user is permanently blocked from using chmod.
RUN chown -R root:frontend_group /frontend-app /etc/nginx /run/nginx /var/cache/nginx /var/log/nginx
# 2. Update the PID path in the nginx config
RUN sed -i 's|.*nginx.pid;|pid /run/nginx/nginx.pid;|g' /etc/nginx/nginx.conf
# 3. Lock down the app and config folders to Read/Execute (750).
# Owner (root): Read/write/Execute (7)
# Group (frontend_group): Read/Execute (5)
# Others: No Access (0)
RUN chmod -R 750 /frontend-app /etc/nginx
# 4. Grant Write access to the specific runtime folders Nginx needs to function and env creation folder (770).
# Owner (root): Read/Write/Execute (7)
# Group (frontend_group): Read/Write/Execute (7)
# Others: No Access (0)
RUN chmod -R 770 /run/nginx /var/cache/nginx /var/log/nginx /frontend-app/mywebsite/env
USER frontend_user

# Expose the port that the application listens on.
EXPOSE 8080
ENTRYPOINT ["/frontend-app/nginx-entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

Also, update your nginx.conf file. With the following changes
change these two lines:

  • From listen 80; to listen 8080;
  • From listen [::]:80; to listen [::]:8080;

Finally, your nginx.cong. Files look like

   server{
    listen 8080;          # Listen for IPv4
    listen [::]:8080;     # Listen for IPv6
    server_name localhost;

    root /frontend-app/mywebsite;
    index index.html;


    location / {
        try_files $uri /index.html;
    }
    }
Enter fullscreen mode Exit fullscreen mode

Also, update the new env path in nginx-entrypoint.sh.

Update the CONFIG_PATH from /frontend-app/mywebsite/env-config.js to /frontend-app/mywebsite/env/env-config.js

After the change, your final nginx-entrypoint.sh file looks like

   #!/bin/sh

# Start the generated config file
CONFIG_PATH="/frontend-app/mywebsite/env/env-config.js"
echo "window.env = {" > $CONFIG_PATH

# Find all environment variables starting with "VITE_" and add them to the file
for var in $(env | grep "VITE_"); do
  name=$(echo "$var" | cut -d "=" -f 1)
  value=$(echo "$var" | cut -d "=" -f 2-)
  echo "  $name: \"$value\"," >> $CONFIG_PATH
done

# End the config file
echo "};" >> $CONFIG_PATH
echo "Environment variable injection completed."
exec nginx -g 'daemon off;'
Enter fullscreen mode Exit fullscreen mode

Also, change the src path in our index.html file.

From <script async src="/env-config.js"></script> to
<script async src="/env/env-config.js"></script>

After the above changes, your index.html file looks like

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script async src="/env/env-config.js"></script>
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

We're done with all the changes. Now, let's build it again and run it to check if everything is working or not

For building run docker build inside the frontend directory

docker build -t my-frontend-secure-todo-multistage:v2 .
Enter fullscreen mode Exit fullscreen mode

It build successfully

image build successfully after securing it

Now start the container. But please delete the older container that we created previously.

Then run the docker run command

docker run -it --env-file ./.env -p 8080:8080 --name todo-app-multistage-secure-container my-frontend-secure-todo-multistage:v2
Enter fullscreen mode Exit fullscreen mode

Our container started without any Permission denied error.

docker container started without any error

Now go to the URL http://localhost:8080. And check now we getting todos or not?

Hurray, it's working 😎

working frontend with todos

Now let's check if our app is still secure or not. Now we again try to perform the same scenario that we performed previously. We try to delete some files inside the mywebsite folder.

To go inside the container, run the docker exec command

docker exec -it todo-app-multistage-secure-container sh
Enter fullscreen mode Exit fullscreen mode

For Windows run

winpty docker exec -it todo-app-multistage-secure-container sh
Enter fullscreen mode Exit fullscreen mode

Once you're inside the container

inside the container

Run the whoami command, and you will see frontend_user after running it.

whoami
Enter fullscreen mode Exit fullscreen mode

You will see

output of whoami

Now go to the /frontend-app/mywebsite directory. And try to delete the index.html file. You will see a permission denied error. frontend_user is not able to delete the index.html file.

Run the cd command to go inside the /frontend-app/mywebsitedirectory.

cd /frontend-app/mywebsite
Enter fullscreen mode Exit fullscreen mode

cd command output

Then run the rm -rf command for deleting the index.html file.

rm -rf index.html
Enter fullscreen mode Exit fullscreen mode

You will see a permission denied error

permission denied error

See our frontend_user working fine, it's unable to delete any files inside the frontend-app. But still it can delete /env/env-config.js file because it had write access for the /env directory. You can try deleting on your end.

So we secured our website from being deleted. Now the hacker is not able to delete our website, at least.

So finally we've done it. And we successfully created our production-ready frontend Dockerfile. Now you can use the same template for any frontend project which mostly based on CSR.

That's all for this blog post. We're done with our blog post. If you want to check out the bonus section. Go and check it for extra knowledge.

And if you follow along till here, pat your back. You did a great job.

12. Bonus: Extra observation on nginx.

Prefer Video? Watch Here

Note:

The core tutorial concludes here. The following section covers some extra observations of mine on nginx. It is strictly optionalβ€”feel free to skip it, or read on to understand the underlying mechanics.

If you noticed or not. The path to default.conf in the Dockerfile changes depending on the image.

In the following Dockerfile, we used a different path to replace the existing default.conf with our custom nginx.conf file.

In node:lts image there we used the path /etc/nginx/sites-available/default

The line we used in our Dockerfile COPY nginx.conf /etc/nginx/sites-available/default

In node:lts-alpine3.22 we used /etc/nginx/http.d/nginx.conf

In the final Stage of the multistage Dockerfile for the image nginx:stable-alpine-slim.
We used path /etc/nginx/conf.d/default.conf

Every time default.conf file path location changes on each Docker file. Why? Because each Linux distro uses a different location for niginx configuration files.

How to know about these paths?

To check it if you're not sure about the path.

Just install nginx and run the container with the default configuration. Now go inside the container via the exec command. And then run the nginx -t command. It will give you the location of the main nginx configuration file. Now run the cat command to check the content of nginx.conf main file content.

Let’s do this as well. Do it in our running container. We just ran the container for our final build

Run command

docker exec -it todo-app-multistage-container /bin/sh
Enter fullscreen mode Exit fullscreen mode

It will connect your terminal to the container. Once you are inside your container. Run below command

niginx -t
Enter fullscreen mode Exit fullscreen mode

Terminal o/p after running nginx -t command

Copy the highlighted path and run the command

cat /etc/nginx/nginx.conf
Enter fullscreen mode Exit fullscreen mode

output of cat command

Are you able to see the content of niginx.conf file. In that file, check for an include statement. It will denote the path where you can put your site config files.

That’s it.

13. Wrap Up:

We're done with our frontend Docker image part. We have seen all the aspects of building images. Now you can also optimize our prod build further by using distroless images. And learn about it more. Stay tuned for the next post, where we will containerize our backend and integrate a MySQL database.

Top comments (0)