Deploy a MERN Stack App on AWS EC2
Build a full-stack Task Manager and deploy it live on AWS EC2.
Introduction
30 Second Summary
Every app you use daily, from your to-do list to your project tracker, has a frontend you interact with, a backend that processes your requests, and a database that remembers your data. Getting all three pieces working together and live on the internet is the core skill that separates local experiments from real software.
In this project, you will build a full-stack Task Manager application using the MERN stack and deploy it to a live server on AWS.
What You'll Build
A Task Manager app where users can create, view, and delete tasks, with a React frontend talking to an Express.js API backed by MongoDB Atlas, all deployed to a live EC2 instance.
By the end of this project, you'll have:
- A working React + Vite frontend that displays and manages tasks through a clean UI.
- An Express.js REST API with CRUD endpoints connected to a cloud MongoDB database.
- A production deployment on AWS EC2 with Nginx serving your app and PM2 keeping your backend running.
- Secret Mission: Add a task completion feature with a progress bar showing the percentage of completed tasks.
Are there any prerequisites?
You should have basic JavaScript knowledge and some familiarity with the command line. No prior experience with React, Express, MongoDB, or AWS is required.
Before We Start
You're about to build a full-stack Task Manager application and deploy it to AWS. Before writing any code, you need the right tools installed on your machine.
This step gets your development environment ready with Node.js (the JavaScript runtime that powers both your backend server and frontend build tools), a code editor, and Git for version control. You'll also create the project folder structure that separates your frontend and backend code.
In this step, get ready to:
- Install Node.js v24 LTS and verify it works.
- Install VS Code and Git.
- Create the project folder with separate client and server directories.
Install Node.js v24 LTS
Node.js lets you run JavaScript outside the browser. Your Express backend runs on it, and your React frontend tooling (Vite) depends on it too.
- Open your browser and go to nodejs.org.
- Click the download button for the LTS version (v24).
- Run the downloaded .msi installer and accept all the default options.
What is Node.js?
Node.js is a runtime that lets JavaScript run on your computer instead of only in a browser. It comes bundled with npm (Node Package Manager), which you'll use to install libraries like Express and React.
Now verify the installation worked.
- Press the Windows key to open your search bar.
- Type Terminal and press Enter to open Windows Terminal.
- Check your Node.js version by running this command:
node --version
✔️ I see version 24 or higher
You're all set. Node.js v24 is ready to go.
ⓧ I see an older version
You have an older version of Node.js installed. This project requires v24 LTS.
- Go to nodejs.org and download the LTS installer for Windows.
- Run the .msi file. The installer will upgrade your existing Node.js to v24.
- Close and reopen Windows Terminal, then run node --version again to confirm it shows v24.
ⓧ Command not found
Node.js isn't installed yet. Let's fix that.
- Go to nodejs.org and download the LTS installer for Windows.
- Run the .msi file and accept all default options.
- Close and reopen Windows Terminal, then run node --version again. You should now see a version number starting with v24.
- Verify npm is also installed by running:
npm --version
You should see a version number (like 10.x or higher). npm is bundled with Node.js, so it installs automatically.
Not seeing a version number for npm?
Make sure you closed and reopened Windows Terminal after installing Node.js. The terminal needs to reload so it can find the new commands.
Help me troubleshoot my Node.js installation on Windows.
Install VS Code and Git
VS Code is the code editor where you'll write your frontend and backend code. Git handles version control and will be used to transfer your code to the EC2 server later.
- Download VS Code from code.visualstudio.com.
- Run the installer and accept all default options.
- Download Git from git-scm.com.
- Run the Git installer and accept all default options.
- Back in Windows Terminal, verify Git is installed by running:
git --version
You should see a version number in the output. If you see a "command not found" error, close and reopen Windows Terminal and try again.
- Press the Windows key to open your search bar.
- Type VS Code and press Enter to open it.
VS Code not showing up in search?
Try searching for "Visual Studio Code" instead. If it still doesn't appear, you may need to restart your computer after the installation.
Help me find VS Code on my Windows machine.
Create the project folder structure
Your project needs two separate directories: one for the React frontend and one for the Express backend. Keeping them apart makes each piece independently deployable.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and the two subdirectories by running these commands:
mkdir task-manager
cd task-manager
mkdir client
mkdir server
What does this create?
You're creating a task-manager folder on your Desktop with two empty subdirectories inside it.
- client will hold your React frontend code.
- server will hold your Express.js backend API.
- Confirm the folders exist by running:
ls
You should see client and server listed in the output.
Not seeing both folders?
Make sure you ran each mkdir command separately. Check that you're inside the task-manager folder (not still on your Desktop) by looking at the path shown in your terminal prompt.
Help me fix my folder structure.
- Open the task-manager folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the task-manager folder, and click Select Folder.
You should see client and server in the VS Code Explorer sidebar on the left.
Your development environment is ready. Next up, you'll build the Express.js backend API that handles creating, reading, and deleting tasks.
Build the Express.js Backend API
Your development environment is ready. Now it's time to build the brains of your Task Manager.
Every full-stack app needs a backend that handles data. In this step, you'll create an Express.js server with REST API endpoints that let the frontend create, read, and delete tasks.
In this step, get ready to:
- Initialize the server project and install dependencies.
- Set up the Express server with a MongoDB connection and Task model.
- Create REST API routes for managing tasks.
Initialize the server project and install dependencies
Before writing any code, you need to turn your server folder into a proper Node.js project and install the packages your API depends on.
- In your VS Code terminal, navigate into the server directory by running this command:
cd server
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The npm init -y command creates a package.json file with default values. This file tracks your project's name, version, and dependencies.
- Confirm the file was created by running:
ls
You should see package.json listed in the output.
- Install the four packages your server needs by running:
npm install express@4.19.2 mongoose@8.14.1 cors@2.8.5 dotenv@16.5.0
What are these packages?
- express is the web framework that handles HTTP requests and responses.
- mongoose connects your app to MongoDB and lets you define data models.
- cors allows your frontend (running on a different port) to talk to your backend.
- dotenv loads secret values (like database passwords) from a .env file so they stay out of your code.
You should see output ending with "added 4 packages" (plus their sub-dependencies). A node_modules folder now appears in your server directory.
npm install failing?
- Make sure you ran cd server first. Running npm install in the wrong folder installs packages in the wrong place.
- Check your internet connection. npm needs to download packages from the registry.
Why is npm install failing for me?
- Open package.json in the VS Code Explorer sidebar (click the top file icon on the left).
- Replace its entire content with the following code:
{
"name": "task-manager-server",
"version": "1.0.0",
"description": "Task Manager API",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"express": "4.19.2",
"mongoose": "8.14.1",
"cors": "2.8.5",
"dotenv": "16.5.0"
}
}
What does this code do?
- The start script runs your server in production mode.
- The dev script uses node --watch to automatically restart the server whenever you save a change.
- Save package.json by pressing Ctrl+S (Windows) or Cmd+S (macOS).
Set up the Express server and database connection
Your server needs a way to store sensitive configuration (like database passwords) without putting them in your code. A .env file keeps these values local to your machine.
- Create a new file called .env inside the server folder. Right-click in the VS Code Explorer sidebar and select New File.
- Name the file .env and paste this content:
MONGODB_URI=mongodb+srv://your-username:your-password-here@your-cluster.mongodb.net/taskmanager?retryWrites=true&w=majority
PORT=5000
What does this file do?
The MONGODB_URI line holds your database connection string. You'll replace the placeholder with real credentials in the next step when you set up MongoDB Atlas.
The PORT line tells Express which port to listen on. Port 5000 keeps it separate from the React frontend (which uses port 5173).
- Save .env.
Now you'll create the main server file. This is where Express receives requests, connects to MongoDB, and defines what a "task" looks like in the database.
- Create a new file called index.js inside the server folder.
- Add the server setup and database connection code by pasting this into index.js:
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 5000;
// Enable CORS so the React frontend can make requests
app.use(cors());
// Parse incoming JSON request bodies
app.use(express.json());
// Connect to MongoDB using the URI from .env
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
What does this code do?
- The first four lines import the packages you installed. require('dotenv').config() loads your .env variables into process.env.
- app.use(cors()) allows requests from different origins. Without this, the browser blocks the React frontend from talking to the backend.
- mongoose.connect() opens a connection to your MongoDB database. It logs a success or error message so you know if it worked.
- Below the mongoose.connect() block, add the Task schema and model by pasting this code:
// Define the shape of a task document in MongoDB
const taskSchema = new mongoose.Schema({
title: { type: String, required: true },
createdAt: { type: Date, default: Date.now }
});
// Create the Task model from the schema
const Task = mongoose.model('Task', taskSchema);
What is a schema?
A Mongoose schema defines the structure of your data. Here, every task must have a title (a required string) and gets an automatic createdAt timestamp.
The Task model gives you methods like .find() and .save() to interact with the tasks collection in MongoDB.
- Save index.js.
You won't see a change yet. The next section adds the routes that bring this server to life.
Create the REST API routes
Routes tell Express what to do when it receives a request at a specific URL. Your Task Manager needs three actions: list all tasks, add a new task, and delete a task.
- In index.js, add the API routes below the Task model line by pasting this code:
// GET all tasks, sorted by newest first
app.get('/api/tasks', async (req, res) => {
const tasks = await Task.find().sort({ createdAt: -1 });
res.json(tasks);
});
// POST a new task
app.post('/api/tasks', async (req, res) => {
const task = new Task({ title: req.body.title });
await task.save();
res.status(201).json(task);
});
// DELETE a task by its ID
app.delete('/api/tasks/:id', async (req, res) => {
await Task.findByIdAndDelete(req.params.id);
res.status(204).send();
});
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
// Start the server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What do these routes do?
- GET /api/tasks retrieves all tasks from MongoDB, sorted with the newest first.
- POST /api/tasks creates a new task using the title sent in the request body.
- DELETE /api/tasks/:id removes a specific task by its MongoDB ID.
- GET /api/health is a simple endpoint you can hit to confirm the server is running.
- Save index.js.
Your server file is complete. You can't test it yet because the MONGODB_URI in your .env file still has placeholder values. You'll fix that in the next step.
Seeing red squiggles in VS Code?
- Check that your file is saved as index.js (not index.js.txt). The file extension matters.
- Make sure the file is inside the server folder (the same folder where node_modules lives).
- Verify that every opening brace { has a matching closing brace } and every opening parenthesis ( has a matching ).
Help me fix syntax errors in my server/index.js file.
✔️ Awesome, I've got everything!
Great. Double check you've saved both index.js and .env inside your server folder.
ⓧ I'd like to double check the full code
Here is the complete server/index.js file:
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
const taskSchema = new mongoose.Schema({
title: { type: String, required: true },
createdAt: { type: Date, default: Date.now }
});
const Task = mongoose.model('Task', taskSchema);
app.get('/api/tasks', async (req, res) => {
const tasks = await Task.find().sort({ createdAt: -1 });
res.json(tasks);
});
app.post('/api/tasks', async (req, res) => {
const task = new Task({ title: req.body.title });
await task.save();
res.status(201).json(task);
});
app.delete('/api/tasks/:id', async (req, res) => {
await Task.findByIdAndDelete(req.params.id);
res.status(204).send();
});
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
And here is the complete server/.env file:
MONGODB_URI=mongodb+srv://your-username:your-password-here@your-cluster.mongodb.net/taskmanager?retryWrites=true&w=majority
PORT=5000
And the complete server/package.json:
{
"name": "task-manager-server",
"version": "1.0.0",
"description": "Task Manager API",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"express": "4.19.2",
"mongoose": "8.14.1",
"cors": "2.8.5",
"dotenv": "16.5.0"
}
}
Your backend API is built and ready to connect to a real database. Next up, you'll create a free MongoDB Atlas cluster and wire it into your server so tasks actually persist.
Set Up MongoDB Atlas Free Cluster
Your Express API has endpoints ready to handle tasks, but it has no database to persist them. Without a database, every task disappears the moment you stop the server.
MongoDB Atlas is a cloud-hosted database service with a free tier that gives you 512 MB of storage. In this step, you'll create a free cluster, configure access, and wire your backend to a live database.
In this step, get ready to:
- Create a MongoDB Atlas account and deploy a free M0 cluster on AWS.
- Configure a database user and network access permissions.
- Connect your backend to the live database and test every API endpoint.
Create a MongoDB Atlas account and deploy a free cluster
MongoDB Atlas hosts your database in the cloud so you do not need to install MongoDB locally. The free M0 tier never expires and gives you enough storage for development.
- Open https://www.mongodb.com/cloud/atlas/register in your browser.
- Sign up for a free account (no credit card required) or sign in if you already have one.
- After signing in, you'll land on the Project Overview page.
- Click the Create button to start creating a new cluster.
- Select the M0 option (the free tier).
- Under Provider, choose AWS.
- Choose a region close to you from the available options.
- Leave the cluster name as the default or enter a name you'll remember.
- Click Create to deploy the cluster.
What is MongoDB Atlas?
MongoDB Atlas is a Database-as-a-Service (DBaaS) that runs MongoDB clusters in the cloud. You interact with it through a web dashboard and connect from your app using a connection string.
The M0 free tier provides 512 MB of storage on shared infrastructure. It runs MongoDB 8.0 and never expires, making it ideal for development and small projects.
Create a database user and configure network access
Atlas requires two things before your app can connect: a database user with a password, and an IP address allowlist.
- After clicking Create, the Security Quickstart wizard appears.
- Enter a username in the Username field (for example, taskadmin).
- Enter a password or copy the one Atlas suggests for you.
- Write down your username and password somewhere safe. You will need them in the next substep.
- Click Create Database User.
Now configure network access so your app can reach the database from any IP.
- In the Security Quickstart wizard, look for the IP Access List section.
- Instead of clicking Add My Current IP Address, click the text input and type 0.0.0.0/0.
- Click Add Entry.
- Click Finish and Close.
- Click Go to Overview.
Why allow all IPs with 0.0.0.0/0?
Setting the IP access list to 0.0.0.0/0 allows connections from any IP address. This is acceptable for development because your database user credentials still protect access.
Later, when you deploy to EC2, the server's IP may change. The open access list means you won't get locked out during deployment. For a production app, you would restrict this to your server's specific IP.
Get the connection string and test the backend locally
With your cluster deployed and access configured, you can now grab the connection string and plug it into your backend.
- On your cluster's overview page, click the Connect button.
- Select Drivers from the connection method options.
- Choose Node.js as the driver.
- Click the copy button next to the connection string to copy it to your clipboard.
The copied string looks something like this:
mongodb+srv://taskadmin:<db_password>@cluster0.abc123.mongodb.net/?retryWrites=true&w=majority
- Switch back to VS Code and open the server/.env file.
- Find this line:
MONGODB_URI=mongodb+srv://your-username:your-password-here@your-cluster.mongodb.net/taskmanager?retryWrites=true&w=majority
- Replace the entire value after MONGODB_URI= with the connection string you copied from Atlas.
- In the pasted string, replace <db_password> with the actual password you created for your database user.
- Add taskmanager as the database name after .mongodb.net/ and before the ? character.
Your final MONGODB_URI line should follow this format:
MONGODB_URI=mongodb+srv://taskadmin:yourActualPassword@cluster0.abc123.mongodb.net/taskmanager?retryWrites=true&w=majority
What does each part of the connection string do?
- mongodb+srv:// tells Mongoose to use the SRV protocol for automatic DNS-based discovery of replica set members.
- taskadmin:yourActualPassword authenticates with the database user you just created.
- cluster0.abc123.mongodb.net is the address of your Atlas cluster (yours will differ).
- /taskmanager tells MongoDB which database to use. Mongoose creates it automatically on first write.
- ?retryWrites=true&w=majority enables automatic retries for failed writes and requires acknowledgment from a majority of replica set members.
- Save server/.env.
Now start the backend server and confirm it connects to MongoDB.
- Open a terminal in VS Code (Ctrl+` or Terminal > New Terminal from the menu).
- Make sure you are in the server directory, then start the server by running:
npm run dev
- Look at the terminal output. You should see both Server running on port 5000 and Connected to MongoDB.
Seeing a MongoDB connection error?
- Double-check that you replaced <db_password> in your connection string with the actual password (not the angle brackets).
- Make sure there are no spaces around the = in your .env file.
- Verify your IP access list includes 0.0.0.0/0 in the Atlas dashboard under Network Access.
- If your password contains special characters like @ or #, they must be percent-encoded in the connection string.
Help me debug my MongoDB connection error
With the server running, test the API endpoints by opening a new browser tab.
- Open http://localhost:5000/api/health in your browser.
- You should see {"status":"ok"} displayed on the page.
- Open http://localhost:5000/api/tasks in your browser.
- You should see an empty array [] because no tasks exist yet.
Now test creating a task using a POST request. Open a second terminal in VS Code and run:
curl -X POST http://localhost:5000/api/tasks -H "Content-Type: application/json" -d "{\"title\":\"My first task\"}"
Using PowerShell on Windows?
The curl command above uses bash syntax. If you are on PowerShell, use this instead:
Invoke-RestMethod -Method POST -Uri http://localhost:5000/api/tasks -ContentType "application/json" -Body '{"title":"My first task"}'
- You should see a JSON response containing your new task with a title, _id, and createdAt field.
- Go back to your browser and refresh http://localhost:5000/api/tasks.
- You should now see your task in the array.
POST request returning an error?
- Make sure the first terminal is still running npm run dev and shows Connected to MongoDB.
- Check that the JSON body uses double quotes around both title and the value.
- Verify the URL includes the correct port 5000.
Help me fix my POST request to the tasks API
✔️ Awesome, I've got everything!
Great. Make sure server/.env is saved with your real connection string.
ⓧ I'd like to double check the full code
Here is what server/.env should look like (with your own values substituted):
MONGODB_URI=mongodb+srv://your-username:your-password-here@your-cluster.mongodb.net/taskmanager?retryWrites=true&w=majority
PORT=5000
Your server/index.js and server/package.json files should remain unchanged from the previous step.
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
const taskSchema = new mongoose.Schema({
title: { type: String, required: true },
createdAt: { type: Date, default: Date.now }
});
const Task = mongoose.model('Task', taskSchema);
app.get('/api/tasks', async (req, res) => {
const tasks = await Task.find().sort({ createdAt: -1 });
res.json(tasks);
});
app.post('/api/tasks', async (req, res) => {
const task = new Task({ title: req.body.title });
await task.save();
res.status(201).json(task);
});
app.delete('/api/tasks/:id', async (req, res) => {
await Task.findByIdAndDelete(req.params.id);
res.status(204).send();
});
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Your backend is now connected to a live MongoDB database and serving real data through the API. Next up, you'll build the React frontend that talks to these endpoints and gives users a visual interface for managing tasks.
Build the React Frontend with Vite
Your backend API is live and connected to MongoDB Atlas. Now it needs a user interface so people can actually interact with it.
In this step, you will build a React frontend using Vite as the build tool. The frontend will display tasks, let users add new ones, and provide a delete button for each task.
In this step, get ready to:
- Scaffold a React app with Vite inside a client directory.
- Build the task list UI with form input and delete buttons.
- Connect the frontend to the backend API through a Vite proxy.
Scaffold the React app with Vite
Vite is a modern build tool that scaffolds a ready-to-go React project in seconds. It also provides a fast dev server with hot module replacement, so changes appear instantly in your browser.
- In VS Code, open a new terminal. Your backend is still running in the other terminal, so leave that one alone.
- Make sure you are in the project root folder (the parent of server). Navigate there by running this command:
cd ~/Desktop/task-manager
- Scaffold a new React app inside a client folder by running:
npm create vite@latest client -- --template react
What does this command do?
This command tells npm to run the create-vite scaffolding tool. The client argument sets the project folder name. The --template react part tells Vite to use the React starter template.
Because you specified both the project name and template on the command line, Vite scaffolds the project without asking any interactive questions.
- Move into the new client folder and install its dependencies by running:
cd client
npm install
- Confirm the scaffold worked by running:
ls src
- You should see files like App.jsx, App.css, and main.jsx in the output.
Don't see App.jsx?
Make sure you included -- --template react at the end of the scaffold command. Without the template flag, Vite uses vanilla JavaScript instead of React.
If the folder was created with the wrong template, delete it and re-run the scaffold command from the project root.
help me fix my Vite scaffold
Build the task list UI
The scaffolded project comes with placeholder code. You will replace it with a task manager component that handles adding, displaying, and deleting tasks.
- In VS Code, open client/src/App.jsx.
- Select all the existing code and replace it with the following:
import { useState, useEffect } from 'react';
import './App.css';
// Use environment variable for API URL, fall back to /api for proxy
const API_URL = import.meta.env.VITE_API_URL || '/api';
function App() {
// State for the list of tasks and the new task input
const [tasks, setTasks] = useState([]);
const [newTask, setNewTask] = useState('');
// Fetch tasks from the backend when the component loads
useEffect(() => {
fetchTasks();
}, []);
// GET all tasks from the API
async function fetchTasks() {
const res = await fetch(`${API_URL}/tasks`);
const data = await res.json();
setTasks(data);
}
// POST a new task to the API
async function addTask(e) {
e.preventDefault();
if (!newTask.trim()) return;
await fetch(`${API_URL}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTask })
});
setNewTask('');
fetchTasks();
}
// DELETE a task by its ID
async function deleteTask(id) {
await fetch(`${API_URL}/tasks/${id}`, { method: 'DELETE' });
fetchTasks();
}
return (
<div className="app">
<h1>Task Manager</h1>
<form onSubmit={addTask} className="task-form">
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add</button>
</form>
<ul className="task-list">
{tasks.map(task => (
<li key={task._id} className="task-item">
<span>{task.title}</span>
<button onClick={() => deleteTask(task._id)} className="delete-btn">Delete</button>
</li>
))}
</ul>
</div>
);
}
export default App;
What does this component do?
- API_URL reads from an environment variable or defaults to /api. The /api path gets proxied to your backend (you will configure that shortly).
- useState holds two pieces of state: the array of tasks from the database and the text the user is typing.
- useEffect calls fetchTasks() once when the component first appears, loading existing tasks from the backend.
- addTask sends a POST request to create a task, clears the input, then refreshes the list.
- deleteTask sends a DELETE request for a specific task ID and refreshes the list.
- Save App.jsx.
- Now open client/src/App.css.
- Select all the existing code and replace it with the following styles for the layout and form:
/* Center the app and set the base font */
.app {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #333;
}
/* Horizontal form layout */
.task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.task-form input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.task-form button {
padding: 0.75rem 1.5rem;
background: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.task-form button:hover {
background: #4338ca;
}
/* Task list with no default bullets */
.task-list {
list-style: none;
padding: 0;
}
.task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.delete-btn {
background: #ef4444;
color: white;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background: #dc2626;
}
What do these styles do?
The .app class centers the whole UI in a 600px column. The .task-form uses flexbox to place the input and button side by side.
Each .task-item is a flex row that pushes the task title to the left and the delete button to the right. The red .delete-btn makes the destructive action visually obvious.
- Save App.css.
✔️ Awesome, I've got everything!
Great. Double check you have saved both App.jsx and App.css.
ⓧ I'd like to double check the full code
Here is the complete client/src/App.jsx:
import { useState, useEffect } from 'react';
import './App.css';
const API_URL = import.meta.env.VITE_API_URL || '/api';
function App() {
const [tasks, setTasks] = useState([]);
const [newTask, setNewTask] = useState('');
useEffect(() => {
fetchTasks();
}, []);
async function fetchTasks() {
const res = await fetch(`${API_URL}/tasks`);
const data = await res.json();
setTasks(data);
}
async function addTask(e) {
e.preventDefault();
if (!newTask.trim()) return;
await fetch(`${API_URL}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTask })
});
setNewTask('');
fetchTasks();
}
async function deleteTask(id) {
await fetch(`${API_URL}/tasks/${id}`, { method: 'DELETE' });
fetchTasks();
}
return (
<div className="app">
<h1>Task Manager</h1>
<form onSubmit={addTask} className="task-form">
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add</button>
</form>
<ul className="task-list">
{tasks.map(task => (
<li key={task._id} className="task-item">
<span>{task.title}</span>
<button onClick={() => deleteTask(task._id)} className="delete-btn">Delete</button>
</li>
))}
</ul>
</div>
);
}
export default App;
Here is the complete client/src/App.css:
.app {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #333;
}
.task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.task-form input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.task-form button {
padding: 0.75rem 1.5rem;
background: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.task-form button:hover {
background: #4338ca;
}
.task-list {
list-style: none;
padding: 0;
}
.task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.delete-btn {
background: #ef4444;
color: white;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background: #dc2626;
}
Connect the frontend to the backend API
Your React app fetches from /api/tasks, but the Vite dev server runs on a different port than your Express backend. A proxy tells Vite to forward any /api request to your backend on port 5000.
- Open client/vite.config.js in VS Code.
- Select all the existing code and replace it with the following:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Proxy /api requests to the Express backend during development
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:5000'
}
}
});
What does the proxy do?
When your React app fetches /api/tasks, Vite intercepts the request and forwards it to http://localhost:5000/api/tasks where your Express server is listening. This avoids cross-origin issues during development.
In production, Nginx will handle this same routing. The proxy is a development convenience only.
- Save vite.config.js.
- Make sure your backend is still running in the other terminal (you should see Connected to MongoDB in that terminal's output).
- Start the Vite dev server by running this command in the client terminal:
npm run dev
- You should see output showing the dev server is running on http://localhost:5173.
- Open http://localhost:5173 in your browser.
- You should see the Task Manager heading, an input field, and an Add button.
- Type a task name into the input and click Add. The task should appear in the list below.
- Click the Delete button next to a task. It should disappear from the list.
Tasks not showing up?
Check that your backend is still running in the other terminal. If it crashed, restart it with npm run dev from the server directory.
Make sure vite.config.js has the proxy pointing to http://localhost:5000. If the port is wrong, the proxy silently fails.
Open your browser's developer console and look for network errors on the /api/tasks request. A 500 error means the backend has an issue. A 404 means the proxy is not configured correctly.
help me debug why my React frontend cannot connect to the Express backend
✔️ Awesome, I've got everything!
Great. Double check you have saved vite.config.js.
ⓧ I'd like to double check the full code
Here is the complete client/vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:5000'
}
}
});
Your full-stack app is working locally. The React frontend talks to Express, which talks to MongoDB Atlas. Next up, you will take this entire stack and deploy it to a live AWS server.
Launch and Configure an AWS EC2 Instance
Your Task Manager app is working locally. The React frontend talks to the Express backend, which stores data in MongoDB Atlas. Now it's time to make this app accessible to the world.
In this step, you'll provision a virtual server on AWS using EC2 (Elastic Compute Cloud). This server will eventually host both your frontend and backend in production.
In this step, get ready to:
- Launch an EC2 instance running Ubuntu.
- Configure a security group to allow web and SSH traffic.
- Connect to the instance via SSH.
Launch a t3.micro EC2 instance with Ubuntu AMI
An EC2 instance is a virtual server that runs in Amazon's data centers. You'll create one running Ubuntu Linux, which will serve as the home for your deployed app.
- Open your browser and navigate to the AWS Management Console at console.aws.amazon.com.
- Sign in to your AWS account (or create a free account if you don't have one).
- In the search bar at the top of the console, type EC2 and click on the EC2 service.
- In the top-right corner, check your region. Pick a region close to you (for example, US East (N. Virginia)). Remember which region you choose.
- Click the orange Launch instance button.
The launch wizard opens. This is a single-page form where you configure your server.
- Under Name and tags, enter task-manager-server as the name.
- Under Application and OS Images (Amazon Machine Image), click Quick Start.
- Select Ubuntu as your operating system.
- Leave the default Ubuntu Server LTS AMI selected. Confirm it says Free tier eligible underneath.
- Under Instance type, select t3.micro from the dropdown. This gives you 1 vCPU and 1 GB of memory, which is plenty for this project.
Now create a key pair so you can SSH into this instance later.
- Under Key pair (login), click Create new key pair.
- Enter task-manager-key as the key pair name.
- Leave the key pair type as RSA.
- Select .pem as the private key file format.
- Click Create key pair. Your browser automatically downloads a file called task-manager-key.pem. Keep this file safe. AWS will not let you download it again.
What is a key pair?
A key pair is like a lock-and-key system for your server. AWS keeps the lock (public key) on the instance, and you download the key (private key) as a .pem file. When you SSH in, you present this key to prove you own the server.
Configure the security group to allow SSH, HTTP, and HTTPS
A security group acts as a firewall for your EC2 instance. It controls which types of network traffic can reach your server. You need to allow SSH for remote access and HTTP/HTTPS for web traffic.
- Scroll down to Network settings in the launch wizard.
- Click Edit to open the network configuration.
- Under Firewall (security groups), select Create security group.
- The first rule (SSH on port 22) is already added. Change the Source type dropdown to Anywhere (0.0.0.0/0).
- Click Add security group rule.
- Set Type to HTTP. This adds port 80. Set the Source type to Anywhere.
- Click Add security group rule again.
- Set Type to HTTPS. This adds port 443. Set the Source type to Anywhere.
Your security group should now have three inbound rules: SSH (port 22), HTTP (port 80), and HTTPS (port 443), all from 0.0.0.0/0.
- Leave all other settings at their defaults (storage, advanced details).
- In the Summary panel on the right side, review your configuration.
- Click Launch instance.
You should see a success message with your instance ID. Click View all instances to go to the Instances page.
- Wait until the Instance state column shows Running and both status checks show green. This takes 1-2 minutes.
- Click on your instance row.
- In the details pane below, find Public IPv4 address and copy this value. You'll need it to connect via SSH.
What is a security group?
Think of a security group as a bouncer at the door of your server. Each inbound rule tells the bouncer which type of visitor to let in. Port 22 allows SSH connections (you, the admin). Port 80 allows regular web traffic. Port 443 allows encrypted web traffic.
Without these rules, no one can reach your server, even if it's running and healthy.
Why allow traffic from 0.0.0.0/0?
The source 0.0.0.0/0 means "from any IP address." For a public web app, HTTP and HTTPS must be open to everyone. SSH from anywhere is acceptable for a learning project, but in production you would restrict it to your own IP address.
Connect to the instance via SSH
Your server is running in the cloud. Now you need to connect to it from your local machine so you can install software and deploy your app.
What is SSH?
SSH (Secure Shell) is a protocol that lets you access a remote computer's command line over an encrypted connection. You'll use it to run commands on your EC2 instance as if you were sitting right in front of it.
First, you need to fix the permissions on your key file. Your SSH client will refuse to use a key file that other users can read.
macOS
- Open Terminal by pressing Cmd+Space, typing Terminal, and pressing Enter.
- Set the correct permissions on your key file by running this command:
chmod 400 ~/Downloads/task-manager-key.pem
- Connect to your EC2 instance by running:
ssh -i ~/Downloads/task-manager-key.pem ubuntu@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
Windows
- Press the Windows key, type Terminal, and press Enter to open Windows Terminal.
- Connect to your EC2 instance by running:
ssh -i C:\Users\[[WINUSER="your Windows username"]]\Downloads\task-manager-key.pem ubuntu@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
What does this command do?
- ssh starts a secure remote connection.
- -i tells SSH which private key file to use for authentication.
- ubuntu is the default username for Ubuntu AMIs on EC2.
- The IP address after @ is your instance's public address.
- When prompted with "Are you sure you want to continue connecting?", type yes and press Enter.
You should see the Ubuntu welcome message and a command prompt that looks like ubuntu@ip-xxx-xxx-xxx-xxx:~$. You are now inside your EC2 instance.
Connection refused or permission denied?
- If you see "Permission denied (publickey)", double-check that you're using the correct .pem file path and that the file permissions are set correctly (macOS/Linux: chmod 400).
- If you see "Connection timed out", make sure your security group has the SSH rule (port 22) and that your instance state is Running.
- On Windows, if you see a "permissions are too open" warning, move the .pem file to your user directory and ensure only your user account has read access.
Help me troubleshoot my SSH connection to EC2.
Your cloud server is live and you have remote access. Next up, you'll install Node.js, transfer your code, and get the Task Manager running on this instance for the world to see.
Deploy the MERN App to EC2
In the previous step, you launched an EC2 instance and established an SSH connection to it.
Now comes the most exciting part of the project. You are going to take the app that works on your local machine and make it live on the internet. This involves installing the same tools on your EC2 server, transferring your code, and configuring a production-grade setup with PM2 and Nginx to keep everything running.
In this step, get ready to:
- Install Node.js, Git, PM2, and Nginx on the EC2 instance.
- Transfer and configure your project code on the server.
- Launch your app to the world with PM2 and Nginx.
Install Node.js, Git, PM2, and Nginx on the EC2 instance
Your EC2 instance is a fresh Ubuntu server with nothing installed beyond the operating system. You need to set up the same runtime and tools that your app depends on.
- In your SSH session, update the package list by running this command:
sudo apt update
- Add the NodeSource repository for Node.js v24 by running:
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
- Install Node.js (which includes npm) by running:
sudo apt-get install -y nodejs
- Verify Node.js installed correctly by running:
node --version
You should see a version number starting with v24.
Not seeing v24?
If you see an older version or an error, the NodeSource script may not have run correctly. Try running sudo apt update again and then re-run the install command.
help me troubleshoot Node.js installation on my Ubuntu EC2 instance
- Install Git, PM2, and Nginx by running:
sudo apt-get install -y git
sudo npm install -g pm2
sudo apt-get install -y nginx
What are PM2 and Nginx?
PM2 is a process manager for Node.js. It keeps your backend server running in the background, restarts it if it crashes, and can auto-start it after a reboot.
Nginx is a web server that serves your React frontend files to browsers. It also acts as a reverse proxy, forwarding API requests from port 80 to your backend on port 5000.
- Confirm all tools are installed by running:
git --version
pm2 --version
nginx -v
You should see version numbers for all three tools.
Transfer project code and start the backend with PM2
Your app code lives on your local machine. The easiest way to get it onto EC2 is to push it to a GitHub repository and clone it from there. If you already have the project on GitHub, you can clone directly.
- On your EC2 instance, create a project directory and navigate into it by running:
mkdir ~/task-manager
cd ~/task-manager
- Create the server directory by running:
mkdir server
cd server
- Initialize the project and install the backend dependencies by running:
npm init -y
npm install express@4.19.2 mongoose@8.14.1 cors@2.8.5 dotenv@16.5.0
- Create the index.js file by running:
nano index.js
- Paste the full backend code into the editor:
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
const taskSchema = new mongoose.Schema({
title: { type: String, required: true },
createdAt: { type: Date, default: Date.now }
});
const Task = mongoose.model('Task', taskSchema);
app.get('/api/tasks', async (req, res) => {
const tasks = await Task.find().sort({ createdAt: -1 });
res.json(tasks);
});
app.post('/api/tasks', async (req, res) => {
const task = new Task({ title: req.body.title });
await task.save();
res.status(201).json(task);
});
app.delete('/api/tasks/:id', async (req, res) => {
await Task.findByIdAndDelete(req.params.id);
res.status(204).send();
});
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
- Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
- Create the .env file with your MongoDB connection string by running:
nano .env
- Paste the following into the file, replacing the placeholder with your actual MongoDB Atlas connection string from Step 3:
MONGODB_URI=mongodb+srv://your-username:your-password-here@your-cluster.mongodb.net/taskmanager?retryWrites=true&w=majority
PORT=5000
- Save and exit nano (Ctrl+O, Enter, Ctrl+X).
Why create files directly on EC2?
For a small project like this, creating files directly on the server with nano is the fastest approach. For larger projects, you would push code to a Git repository and clone it onto EC2.
The .env file should always be created directly on the server, never committed to Git, because it contains your database credentials.
- Start the backend with PM2 by running:
pm2 start index.js --name task-manager-api
What does this command do?
The --name task-manager-api flag gives your process a human-readable name. PM2 runs the server in the background, so you get your terminal prompt back immediately.
- Verify the backend is running by checking the PM2 process list:
pm2 list
You should see task-manager-api listed with a status of online.
- Test the health endpoint to confirm the backend is responding:
curl http://localhost:5000/api/health
You should see {"status":"ok"} in the output.
- Configure PM2 to start automatically on system reboot by running:
pm2 startup
PM2 will print a command that you need to copy and paste back into the terminal. It starts with sudo and configures a system service.
- Copy and paste the command that PM2 printed, then save the process list by running:
pm2 save
What does pm2 save do?
This freezes your current process list. If the server ever reboots, PM2 will automatically restart task-manager-api without you needing to SSH in and start it manually.
Backend showing errored status?
If PM2 shows your process as errored, check the logs with pm2 logs task-manager-api. The most common cause is an incorrect MongoDB connection string in your .env file.
help me debug my PM2 process that shows errored status on EC2
Build the React frontend and configure Nginx
Your backend is running, but nobody can reach your app yet. Nginx will serve the React frontend on port 80 (the standard web port) and forward any /api requests to your backend on port 5000.
- Navigate back to the project root and create the client directory:
cd ~/task-manager
mkdir -p client/src
- Create the client/package.json by running:
cd client
nano package.json
- Paste this content into the editor:
{
"name": "task-manager-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.2",
"vite": "^8.0.0"
}
}
- Save and exit nano.
- Create vite.config.js by running:
nano vite.config.js
- Paste this content:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:5000'
}
}
});
- Save and exit. Then create index.html in the client root:
nano index.html
- Paste this content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Task Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
- Save and exit. Create the main entry point:
nano src/main.jsx
- Paste this content:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
- Save and exit. Create src/App.jsx:
nano src/App.jsx
- Paste the full React component code:
import { useState, useEffect } from 'react';
import './App.css';
const API_URL = import.meta.env.VITE_API_URL || '/api';
function App() {
const [tasks, setTasks] = useState([]);
const [newTask, setNewTask] = useState('');
useEffect(() => {
fetchTasks();
}, []);
async function fetchTasks() {
const res = await fetch(`${API_URL}/tasks`);
const data = await res.json();
setTasks(data);
}
async function addTask(e) {
e.preventDefault();
if (!newTask.trim()) return;
await fetch(`${API_URL}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTask })
});
setNewTask('');
fetchTasks();
}
async function deleteTask(id) {
await fetch(`${API_URL}/tasks/${id}`, { method: 'DELETE' });
fetchTasks();
}
return (
<div className="app">
<h1>Task Manager</h1>
<form onSubmit={addTask} className="task-form">
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add</button>
</form>
<ul className="task-list">
{tasks.map(task => (
<li key={task._id} className="task-item">
<span>{task.title}</span>
<button onClick={() => deleteTask(task._id)} className="delete-btn">Delete</button>
</li>
))}
</ul>
</div>
);
}
export default App;
- Save and exit. Create src/App.css:
nano src/App.css
- Paste the full CSS:
.app {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #333;
}
.task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.task-form input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.task-form button {
padding: 0.75rem 1.5rem;
background: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.task-form button:hover {
background: #4338ca;
}
.task-list {
list-style: none;
padding: 0;
}
.task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.delete-btn {
background: #ef4444;
color: white;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background: #dc2626;
}
- Save and exit. Install frontend dependencies and build the production files:
npm install
npm run build
This creates a dist/ folder containing static HTML, CSS, and JavaScript files ready for Nginx to serve.
Build failing?
If the build fails, make sure all files are saved correctly with no typos. Run ls src/ to confirm App.jsx, App.css, and main.jsx all exist.
help me fix my Vite build failing on EC2
- Now configure Nginx to serve the frontend and proxy API requests. Open the default Nginx config:
sudo nano /etc/nginx/sites-available/default
- Delete all existing content in the file (select all with Ctrl+K repeatedly) and paste this configuration:
server {
listen 80;
server_name _;
root /home/ubuntu/task-manager/client/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
What does this Nginx config do?
The location / block serves your React app. The try_files directive ensures that all routes (like refreshing the page) load index.html correctly.
The location /api block forwards any request starting with /api to your Express backend on port 5000. This is called a reverse proxy.
- Save and exit nano.
- Test the Nginx configuration for syntax errors by running:
sudo nginx -t
You should see a message saying the configuration test is successful.
- Restart Nginx to apply the new configuration:
sudo systemctl restart nginx
sudo systemctl enable nginx
- Open your browser and navigate to your EC2 instance's public IP address (just the IP, no port number needed).
You should see your Task Manager app loaded in the browser. Try adding a task to confirm the frontend, backend, and database are all connected.
Seeing the default Nginx page or a connection error?
If you see the Nginx welcome page, the config file may not have saved correctly. Run sudo cat /etc/nginx/sites-available/default to check its contents.
If the page does not load at all, confirm your security group allows HTTP traffic on port 80. Go to your EC2 instance in the AWS Console, click Security, and verify the inbound rules include port 80.
help me troubleshoot why my MERN app is not loading on EC2 public IP
Your app is live on the internet. Anyone with your EC2 public IP can now use your Task Manager. Next up, you will tackle a bonus challenge to add task completion tracking.
Secret mission
Add Task Completion with Progress Bar
Your Task Manager lets users add and delete tasks, but there's no way to mark them as done. In this secret mission, you'll add a completion toggle to each task and a progress bar that shows what percentage of tasks are finished. Then you'll redeploy the upgraded app to your live EC2 instance.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The EC2 instance is free-tier eligible, and the MongoDB Atlas cluster is free forever.
Cost warning
Your EC2 t3.micro instance uses free tier hours. Legacy AWS accounts get 750 hours/month for 12 months. Newer accounts (post-July 2025) receive credits that cover 6 months of usage.
If your free tier expires or you exceed the allowance, you will be billed for the running instance. Stop or terminate it when you are not actively using it.
Resources you used:
- AWS EC2 instance (t3.micro, Ubuntu).
- MongoDB Atlas free cluster (M0).
- Local project files on your Desktop.
- EC2 key pair .pem file on your local machine.
Keep everything running
No action needed. Choose this if you are still actively building or want to show your app to others.
- Your app stays live at your EC2 public IP address.
- Monitor your free tier usage in the AWS Billing dashboard to avoid surprise charges.
- MongoDB Atlas free clusters never expire. Your data persists indefinitely.
- Node.js, Git, and VS Code remain installed on your local machine.
Pause - I'll come back to this later
Stop the EC2 instance to preserve free tier hours. Your data stays safe and you can restart anytime.
- Open the AWS EC2 console.
- In the navigation pane, click Instances.
- Select your instance.
- Click Instance state, then Stop instance.
- Click Stop when prompted for confirmation.
Your MongoDB Atlas free cluster does not need pausing. It stays active at no cost. Atlas automatically pauses free clusters after 30 days of inactivity anyway.
To resume later, select your stopped instance and click Instance state > Start instance.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your instance, cluster, and local files.
Terminate your EC2 instance
- Open the AWS EC2 console.
- In the navigation pane, click Instances.
- Select your instance.
- Click Instance state, then Terminate (delete) instance.
- Click Terminate (delete) when prompted for confirmation.
Delete your MongoDB Atlas cluster
- Log in to MongoDB Atlas and go to the Clusters page for your project.
- Click the ellipsis menu (the three dots) next to your cluster.
- Click Terminate from the dropdown menu.
- Type your cluster name to confirm, then click Terminate.
Delete your local project files
- Delete the project folder on your Desktop by running this command:
macOS
rm -rf ~/Desktop/task-manager
Windows
Remove-Item -Recurse -Force ~\Desktop\task-manager
Delete your key pair file
- Delete the .pem file you downloaded when launching the EC2 instance. It is likely in your Downloads folder.
macOS
rm ~/Downloads/*.pem
Windows
Remove-Item ~\Downloads\*.pem
That's a Wrap!
That's a Wrap!
Nice work! You've built a full-stack MERN application from scratch and deployed it to a live AWS EC2 instance.
You've learned how to:
- Build a React + Vite frontend with task management UI connected to an Express.js REST API and MongoDB Atlas database.
- Provision and configure an AWS EC2 instance with security groups for SSH, HTTP, and HTTPS access.
- Deploy to production using PM2 for process management and Nginx as a reverse proxy serving static files and proxying API requests.
- Added task completion tracking with a visual progress bar showing percentage of completed tasks.
Ready to quiz yourself?