Containerizing and Deploying a MERN Laptop Rental Application with Docker
Building a full-stack application is only one part of the problem. Running that application reliably is a completely different challenge.
In this project, I built a laptop rental platform using the MERN stack and then worked through the practical problems involved in running it as a containerized application.
The project uses React with Vite for the frontend, Node.js and Express for the backend, MongoDB for data storage, and Razorpay for payments. Docker is used to package the application, while Nginx handles frontend delivery and API reverse proxying.
The goal was not just to make the application work locally, but to build an architecture that can eventually be deployed and managed using modern DevOps practices.
Project Architecture
The application is divided into two primary services:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
User
|
v
+-------------+
| Nginx |
| :80 |
+------+------+
|
+----------+----------+
| |
v v
React/Vite SPA /api/*
|
v
+-------------+
| Node/Express|
| :5000 |
+------+------+
|
+------------+------------+
| |
v v
MongoDB Razorpay
PaymentsThe frontend is responsible for the user interface, while the backend exposes REST APIs for authentication, laptops, rentals, payments, and other application functionality.
Nginx sits in front of the application and provides a single entry point for the frontend and backend API.
Why Docker?
During development, it is easy to run the frontend and backend directly on a local machine.
However, deployment introduces additional concerns:
- Different Node.js versions
- Different operating system environments
- Dependency installation
- Environment variables
- Network configuration
- Persistent files
- Application health
- Reproducible deployments
Docker solves many of these problems by packaging the application and its runtime dependencies into reproducible containers.
Instead of asking a server to have the correct development environment, the deployment can run the same container image that was tested during CI.
Containerizing the Backend
The backend is a Node.js and Express application.
The Docker image contains the backend application and its production dependencies.
A multi-stage approach can be used to keep the final image focused on what is required to run the application.
The backend exposes port
5000 inside the container.The application also provides a health endpoint:
1
GET /healthA successful request returns:
1
2
3
4
{
"status": "ok",
"service": "backend"
}This endpoint is useful for both Docker health checks and future Kubernetes probes.
For example, the Docker Compose configuration can use:
1
2
3
4
5
6
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20sThis is a small addition, but it becomes important when moving toward production orchestration.
Containerizing the Frontend
The frontend is built using React and Vite.
The production workflow is different from development.
During development, Vite provides a development server. For production, the application is first compiled into static assets.
The resulting files can then be served by Nginx.
The general flow is:
1
2
3
4
5
6
7
8
9
10
React source
|
v
npm run build
|
v
Static files
|
v
NginxThis approach avoids running the Vite development server in production.
It also results in a relatively small runtime container because the final image only needs the generated frontend files and Nginx.
Nginx as the Reverse Proxy
One issue that appears when deploying a frontend and backend separately is deciding how the browser should communicate with the API.
Instead of exposing multiple public endpoints, I use Nginx as the entry point.
The browser communicates with:
1
/api/...Nginx forwards those requests to the backend container.
Conceptually:
1
2
3
4
5
6
7
8
9
Browser
|
| /api/laptops
v
Nginx
|
| proxy_pass
v
Backend :5000At the same time, requests for the frontend are handled by Nginx itself.
This gives the application a much cleaner architecture:
1
2
https://example.com
https://example.com/api/...rather than requiring the frontend to know the internal Docker hostname of the backend.
Docker Compose
For local and development environments, Docker Compose makes it possible to run the services together.
A simplified architecture looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
services:
backend:
build: ./backend
ports:
- "5000:5000"
frontend:
build: ./frontend
ports:
- "3000:80"
depends_on:
- backendThe actual project also uses environment variables for configuration and a persistent volume for uploaded files.
For example:
1
2
volumes:
- ./backend/uploads:/app/uploadsThis is important because container filesystems are ephemeral.
If uploaded files exist only inside a container and that container is removed, those files can disappear.
Handling Environment Variables
One of the problems I encountered while developing the application was separating frontend and backend configuration correctly.
Vite exposes client-side environment variables using the
VITE_ prefix.For example:
1
VITE_BACKEND_ENDPOINTThe backend uses its own environment variables for values such as:
1
2
3
4
5
6
MONGO_URI
JWT_SECRET
PORT
FRONT_END_URL
RAZORPAY_KEY_ID
RAZORPAY_KEY_SECRETSecrets should not be hardcoded into the Docker image or committed to Git.
Instead, they should be injected into the application environment during deployment.
For example:
1
2
3
4
5
environment:
MONGO_URI: ${MONGO_URI}
JWT_SECRET: ${JWT_SECRET}
RAZORPAY_KEY_ID: ${RAZORPAY_KEY_ID}
RAZORPAY_KEY_SECRET: ${RAZORPAY_KEY_SECRET}For a production AWS deployment, these values can eventually be moved to a dedicated secret-management solution rather than being maintained in a local
.env file.Persistent Uploaded Files
The application allows files to be uploaded and served from the backend.
The backend exposes the upload directory:
1
/uploadsDocker Compose maps this directory to the host:
1
2
volumes:
- ./backend/uploads:/app/uploadsThis keeps uploaded files outside the container lifecycle.
However, this approach is mainly suitable for simpler deployments.
For a production AWS architecture, object storage such as Amazon S3 is a more natural solution for user-uploaded files.
That would change the architecture from:
1
2
3
4
5
6
7
User
|
v
Backend
|
v
Local uploads directoryto:
1
2
3
4
5
6
7
User
|
v
Backend
|
v
Amazon S3This also makes it easier to run multiple backend instances without relying on shared local storage.
Integrating Razorpay
The application also integrates Razorpay for rental payments.
Payment configuration is separated into environment variables rather than being embedded directly into the application source code.
This distinction is especially important when the application moves from local development to a CI/CD environment.
The frontend only receives values that are safe to expose to the client, while sensitive credentials remain on the backend.
During development, I also encountered configuration-related payment errors. Debugging these issues highlighted an important lesson:
A working application locally does not necessarily mean the production configuration is correct.
Environment variables, API keys, URLs, CORS configuration, and reverse-proxy paths all need to be considered together.
Debugging API Routing
One of the practical problems I encountered was incorrect API URL construction.
An API request was being generated similar to:
1
/api/api/laptopsinstead of:
1
/api/laptopsThis happened because both the Axios base URL and request path contained
/api.The solution was to define a clear API boundary.
For example:
1
2
3
4
5
Base URL:
http://backend:5000/api
Request:
GET /laptopsor, when using Nginx:
1
2
3
4
5
Browser:
GET /api/laptops
Nginx:
-> backend:5000/api/laptopsHaving one clearly defined API boundary prevents a surprising number of deployment bugs.
Image Optimization
Container images also matter in CI/CD.
Large images increase:
- Build time
- Registry storage
- Network transfer time
- Deployment time
The project was therefore optimized to avoid unnecessarily shipping development dependencies and build-time artifacts into the production runtime.
The resulting images were approximately:
1
2
Backend: ~316 MB
Frontend: ~95 MBThese numbers can still be improved further, particularly for the backend, by reviewing dependencies, using an appropriate Node.js base image, and ensuring that only production dependencies are included.
Security Considerations
Containerization does not automatically make an application secure.
I also used
npm audit to identify dependency vulnerabilities.This is useful because vulnerabilities can exist in transitive dependencies that the application does not directly import.
A CI pipeline can eventually automate this process:
1
2
3
4
5
6
7
8
9
10
11
12
13
Git Push
|
v
Install dependencies
|
v
Run tests
|
v
npm audit / security checks
|
v
Build Docker imageThe important point is that security checks should happen before an image reaches production.
From CI to CD
GitHub Actions is well suited for the continuous integration part of this project.
A typical workflow can perform:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Developer
|
| git push
v
GitHub
|
v
GitHub Actions
|
+--> Install dependencies
|
+--> Run tests
|
+--> Security checks
|
+--> Build Docker image
|
+--> Push image to registryAt this stage, the application has continuous integration.
The next question is how the new image should reach Kubernetes.
Moving Toward GitOps
For the deployment side, I am using a GitOps approach with Argo CD.
Instead of GitHub Actions directly connecting to a Kubernetes server and running deployment commands, the deployment configuration becomes another source-controlled artifact.
The workflow becomes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Developer
|
v
GitHub
|
v
GitHub Actions
|
+--> Build Docker image
|
+--> Push image
|
v
Container Registry
|
v
Update Kubernetes manifests
|
v
Git Repository
|
v
Argo CD
|
v
KubernetesArgo CD watches the Git repository and synchronizes the Kubernetes cluster with the desired state defined in Git.
This is the GitOps model:
1
2
3
4
5
Git = Desired State
Kubernetes = Actual State
Argo CD continuously works to keep them synchronized.This provides a cleaner separation between CI and CD.
GitHub Actions is responsible primarily for building and validating the application, while Argo CD handles deployment synchronization.
Kubernetes Architecture
The next stage of the project is to move the containerized application from Docker Compose to Kubernetes.
A simplified Kubernetes architecture could look like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Internet
|
v
Load Balancer
|
v
Ingress
|
+---------+---------+
| |
v v
Frontend Backend
Deployment Deployment
| |
v v
Frontend Pods Backend Pods
|
+-------+-------+
| |
v v
MongoDB External APIs
RazorpayKubernetes provides capabilities that are difficult to achieve with a single Docker host, including:
- Self-healing
- Multiple replicas
- Rolling deployments
- Service discovery
- Resource limits
- Health probes
- Horizontal scaling
Deploying on AWS
The natural next step is deploying the application to AWS.
For a Kubernetes-based production architecture, Amazon EKS can provide the managed Kubernetes control plane.
A possible architecture is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Internet
|
v
AWS Load Balancer
|
v
Amazon EKS
|
+------------+------------+
| |
v v
Frontend Pods Backend Pods
|
+----------+----------+
| |
v v
MongoDB Amazon S3
UploadsGitHub Actions handles CI, Argo CD handles GitOps-based deployment, and EKS provides the Kubernetes environment.
This creates a complete workflow from source code to production:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Code
|
v
GitHub
|
v
GitHub Actions
|
+--> Test
+--> Security Scan
+--> Docker Build
+--> Push Image
|
v
Git Repository
|
v
Argo CD
|
v
Amazon EKS
|
v
Running ApplicationLessons Learned
The biggest lesson from this project was that application development and production engineering are two different skill sets.
Building the application taught me about:
- React
- Node.js
- Express
- MongoDB
- Authentication
- Payments
- REST APIs
Containerizing and deploying it introduced another set of problems:
- Docker
- Networking
- Nginx
- Environment management
- Persistent storage
- Health checks
- Image optimization
- CI/CD
- Kubernetes
- GitOps
Many of the hardest problems were not caused by the application code itself. They appeared at the boundaries between components.
For example:
1
2
3
4
5
6
7
Frontend <-> Nginx
Nginx <-> Backend
Backend <-> MongoDB
Backend <-> Razorpay
Docker <-> Host
GitHub Actions <-> Container Registry
Argo CD <-> KubernetesUnderstanding these boundaries is a major part of building reliable systems.
Conclusion
This laptop rental application started as a MERN stack project, but the project evolved into an opportunity to learn the complete software delivery lifecycle.
The current architecture uses Docker and Nginx to package and serve the application, while the next stage focuses on Kubernetes, GitHub Actions, and Argo CD.
The overall goal is to move from:
1
"It works on my machine."to:
1
"It can be built, tested, deployed, observed, and reproduced reliably."That transition is where application development starts becoming DevOps and cloud engineering.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article