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 

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 

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/ 

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 

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 

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 

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 

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 

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 . . 

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 

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/ 

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 

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;"] 

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;"] 

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 

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

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 

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 

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 . 

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 

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 

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 

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 

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;"] 

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 . 

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 

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 

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

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 

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

Related posts