Deploy a Full-Stack TypeScript App to EC2
Build a React + Express + PostgreSQL to-do app and deploy it to AWS EC2.
Introduction
30 Second Summary
Every web app you use started as code on someone's laptop. The magic happens when that code leaves the laptop and runs on a server anyone in the world can reach.
In this project, you will build a full-stack to-do list application in TypeScript, run it locally with Docker, and deploy it to a live server on AWS so anyone can access it from their browser.
What You'll Build
A complete to-do app with a React frontend, an Express.js backend API, and a PostgreSQL database, all written in TypeScript and deployed to AWS EC2.
By the end of this project, you'll have:
- A working full-stack TypeScript app with add, complete, and delete functionality.
- A Docker Compose setup that runs your app and database locally in containers.
- A live deployment on EC2 accessible from any browser via a public IP address.
- Secret Mission: Add a health check endpoint and harden your security group to lock down server access.
Are there any prerequisites?
You should have a basic understanding of HTML and JavaScript. No prior experience with TypeScript, Docker, or AWS is needed.
Set Up Your Development Environment
A full-stack TypeScript app needs a clear separation between frontend and backend code.
In this step, you'll create the project folder structure and install all the server-side packages needed to start building your Express API.
In this step, get ready to:
- Create the todo-app project folder with server and client subdirectories.
- Initialize the server with npm and install TypeScript, Express, and PostgreSQL packages.
Create the project folder structure
Your project will have a server/ folder for the Express API and a client/ folder for the React frontend.
- Open the integrated terminal in VS Code by pressing Ctrl+` (the backtick key, usually above Tab).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and subdirectories by running these commands:
mkdir todo-app
cd todo-app
mkdir server client
- Confirm the folders exist by running:
ls
You should see client and server in the output.
Don't see both folders?
Make sure you ran each mkdir command from inside the todo-app folder. If you're not sure where you are, run pwd to check your current directory.
help me fix my folder structure
Now open this folder in VS Code so your editor is rooted at the project.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the todo-app folder, then click Open.
You should see server and client in the Explorer sidebar on the left.
Initialize the server and install dependencies
The server needs its own package.json to track its dependencies separately from the client.
- Open a new integrated terminal in VS Code by pressing Ctrl+`.
- Navigate into the server folder and initialize npm by running these commands:
cd server
npm init -y
You should see a message confirming package.json was created.
What does npm init -y do?
This creates a package.json file with default values. The -y flag accepts all defaults so you don't have to answer prompts one by one.
The generated file needs a few edits to support TypeScript and ES modules.
- In the Explorer sidebar, expand the server folder and click package.json to open it.
- Select all the content and replace it with this:
{
"name": "todo-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
What does this code do?
Setting "type": "module" tells Node.js to use ES module syntax (import/export) instead of the older require() style.
The three scripts give you shortcuts: dev runs TypeScript directly during development, build compiles to JavaScript, and start runs the compiled production code.
- Save server/package.json.
- Install the production dependencies by running this command in your terminal:
npm install express@5.2.1 pg@8.19.0 cors@2.8.6
What are these packages?
express is the web framework that handles your API routes. pg is the PostgreSQL client for Node.js. cors allows your React frontend to talk to the API during development.
- Install the development dependencies by running:
npm install -D typescript@5.8.3 tsx@4.19.4 @types/express@5.0.2 @types/cors@2.8.19 @types/pg@8.15.2
Why so many dev dependencies?
typescript is the compiler that checks your types. tsx runs TypeScript files directly without a separate build step during development.
The @types/ packages provide TypeScript type definitions for Express, CORS, and pg so your editor can offer autocompletion and catch errors.
- Verify the install succeeded by running:
ls
You should see node_modules, package.json, and package-lock.json in the output.
Seeing install errors?
Make sure your terminal is inside the server folder. Run pwd to check. If you're at todo-app/ instead of todo-app/server/, run cd server first.
If you see network timeout errors, check your internet connection and try again.
help me fix my npm install errors
✔️ Awesome, I've got everything!
Great. Double check you've saved server/package.json and that node_modules exists in your server folder.
ⓧ I'd like to double check the full code
Here is what your server/package.json should look like after both install commands have run:
{
"name": "todo-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"cors": "2.8.6",
"express": "5.2.1",
"pg": "8.19.0"
},
"devDependencies": {
"@types/cors": "2.8.19",
"@types/express": "5.0.2",
"@types/pg": "8.15.2",
"tsx": "4.19.4",
"typescript": "5.8.3"
}
}
Your project structure is ready and all server dependencies are installed. Next up, you'll build the TypeScript backend API with Express routes and a PostgreSQL connection.
Build the TypeScript Backend API
Your project structure is set up and all dependencies are installed. Now it's time to write the actual backend that powers your to-do app.
In this step, you'll create an Express server written in TypeScript that connects to a PostgreSQL database and exposes a REST API for creating, reading, updating, and deleting to-do items.
In this step, get ready to:
- Configure TypeScript for the server and create the database connection module.
- Build the Express server with four CRUD API routes.
- Start PostgreSQL with Docker Compose and test the backend locally.
Configure TypeScript and create the database module
TypeScript needs a configuration file to know how to compile your code. You also need a database module that connects to PostgreSQL and creates the table where your to-dos will live.
- In VS Code, create a new file at server/tsconfig.json and paste the following configuration:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
What does this configuration do?
This tells TypeScript how to compile your server code into JavaScript.
- module and moduleResolution set to NodeNext align with how Node.js resolves ES modules. This requires .js extensions in import paths, even though the source files are .ts.
- outDir tells TypeScript to output compiled JavaScript into a dist/ folder.
- strict enables full type checking so you catch errors early.
- Save server/tsconfig.json.
- Now create the database connection file at server/src/database.ts and paste the following code:
import pg from 'pg';
const { Pool } = pg;
// Create a connection pool using DATABASE_URL or local defaults
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://todouser:todopass@localhost:5432/tododb',
});
// Create the todos table if it doesn't exist yet
export async function initDb(): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
text TEXT NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
)
`);
}
export default pool;
What does this code do?
This module handles the database connection for your entire backend.
- Pool creates a pool of reusable database connections. A pool is more efficient than opening a new connection for every request.
- connectionString reads from the DATABASE_URL environment variable. In production this will point to the Docker network. Locally it falls back to the default credentials.
- initDb() runs a SQL statement that creates the todos table the first time the server starts. If the table already exists, it does nothing.
- Save server/src/database.ts.
- You won't see any visible change yet. The next substep builds the Express server that uses this module.
VS Code showing a red underline on the import?
If you see an error on import pg from 'pg', make sure you saved tsconfig.json first. TypeScript needs that config to resolve modules correctly.
Also confirm that npm install completed successfully in Step 1. The @types/pg package provides the type definitions.
Why is VS Code showing type errors on my pg import?
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete server/src/database.ts file:
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://todouser:todopass@localhost:5432/tododb',
});
export async function initDb(): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
text TEXT NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
)
`);
}
export default pool;
Create the Express server with API routes
This is the heart of your backend. The Express server handles incoming HTTP requests from the frontend and translates them into database operations.
- Create a new file at server/src/index.ts and paste the following code:
import express, { Request, Response } from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import pool, { initDb } from './database.js';
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const port = process.env.PORT || 3000;
// Enable CORS so the frontend dev server can reach this API
app.use(cors());
app.use(express.json());
// Serve static React build in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'public')));
}
// TypeScript interface for a todo item
interface Todo {
id: number;
text: string;
completed: boolean;
created_at: string;
}
// GET all todos, sorted newest first
app.get('/api/todos', async (_req: Request, res: Response) => {
const result = await pool.query<Todo>('SELECT * FROM todos ORDER BY created_at DESC');
res.json(result.rows);
});
// POST a new todo
app.post('/api/todos', async (req: Request, res: Response) => {
const { text } = req.body as { text: string };
if (!text || text.trim() === '') {
res.status(400).json({ error: 'Text is required' });
return;
}
const result = await pool.query<Todo>(
'INSERT INTO todos (text) VALUES ($1) RETURNING *',
[text.trim()]
);
res.status(201).json(result.rows[0]);
});
// PUT toggles a todo's completed status
app.put('/api/todos/:id', async (req: Request, res: Response) => {
const { id } = req.params;
const todo = await pool.query<Todo>('SELECT * FROM todos WHERE id = $1', [id]);
if (todo.rows.length === 0) {
res.status(404).json({ error: 'Todo not found' });
return;
}
const result = await pool.query<Todo>(
'UPDATE todos SET completed = NOT completed WHERE id = $1 RETURNING *',
[id]
);
res.json(result.rows[0]);
});
// DELETE a todo by id
app.delete('/api/todos/:id', async (req: Request, res: Response) => {
const { id } = req.params;
const result = await pool.query('DELETE FROM todos WHERE id = $1', [id]);
if (result.rowCount === 0) {
res.status(404).json({ error: 'Todo not found' });
return;
}
res.status(204).end();
});
// Catch-all: serve React app for non-API routes in production
if (process.env.NODE_ENV === 'production') {
app.get('*', (_req: Request, res: Response) => {
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
});
}
// Initialize the database, then start listening
initDb().then(() => {
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
});
What does this server do?
This file sets up the full Express API. Here's how the key sections work:
- The import from './database.js' uses a .js extension even though the source file is .ts. This is required by the NodeNext module resolution because TypeScript compiles .ts files to .js at runtime.
- CORS middleware allows the Vite dev server (running on a different port) to call this API during development.
- The four routes map to standard CRUD operations: GET (read all), POST (create), PUT (toggle complete), DELETE (remove).
- In production, Express also serves the built React files as static assets. This means the backend and frontend run from a single server.
- Save server/src/index.ts.
- You won't see a change yet. The next substep starts the database and runs the server.
Seeing red underlines on the Express imports?
Make sure server/tsconfig.json is saved and that the node_modules folder exists inside server/. If it doesn't, return to the server/ directory in your terminal and run npm install again.
Help me fix TypeScript import errors in my Express server file.
✔️ Awesome, I've got everything!
Great. Double check that server/src/index.ts is saved before continuing.
ⓧ I'd like to double check the full code
Here is the complete server/src/index.ts file:
import express, { Request, Response } from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import pool, { initDb } from './database.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const port = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
// Serve static React build in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'public')));
}
interface Todo {
id: number;
text: string;
completed: boolean;
created_at: string;
}
app.get('/api/todos', async (_req: Request, res: Response) => {
const result = await pool.query<Todo>('SELECT * FROM todos ORDER BY created_at DESC');
res.json(result.rows);
});
app.post('/api/todos', async (req: Request, res: Response) => {
const { text } = req.body as { text: string };
if (!text || text.trim() === '') {
res.status(400).json({ error: 'Text is required' });
return;
}
const result = await pool.query<Todo>(
'INSERT INTO todos (text) VALUES ($1) RETURNING *',
[text.trim()]
);
res.status(201).json(result.rows[0]);
});
app.put('/api/todos/:id', async (req: Request, res: Response) => {
const { id } = req.params;
const todo = await pool.query<Todo>('SELECT * FROM todos WHERE id = $1', [id]);
if (todo.rows.length === 0) {
res.status(404).json({ error: 'Todo not found' });
return;
}
const result = await pool.query<Todo>(
'UPDATE todos SET completed = NOT completed WHERE id = $1 RETURNING *',
[id]
);
res.json(result.rows[0]);
});
app.delete('/api/todos/:id', async (req: Request, res: Response) => {
const { id } = req.params;
const result = await pool.query('DELETE FROM todos WHERE id = $1', [id]);
if (result.rowCount === 0) {
res.status(404).json({ error: 'Todo not found' });
return;
}
res.status(204).end();
});
// Catch-all: serve React app for non-API routes in production
if (process.env.NODE_ENV === 'production') {
app.get('*', (_req: Request, res: Response) => {
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
});
}
initDb().then(() => {
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
});
Start PostgreSQL and test the backend
Your code is ready, but it needs a running PostgreSQL database to connect to. Docker Compose lets you define and start the database with a single command.
- Create a new file called docker-compose.yml in the todo-app/ root folder (not inside server/) and paste this configuration:
services:
db:
image: postgres:17
restart: always
shm_size: 128mb
ports:
- "5432:5432"
environment:
POSTGRES_USER: todouser
POSTGRES_PASSWORD: todopass
POSTGRES_DB: tododb
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
What does this Docker Compose file do?
This file tells Docker to run a PostgreSQL 17 container with specific settings.
- image: postgres:17 pulls the official PostgreSQL 17 image from Docker Hub.
- The environment section creates a database called tododb with the username todouser and password todopass. These match the fallback connection string in database.ts.
- ports: "5432:5432" maps the container's PostgreSQL port to your machine so the server can connect.
- volumes persists your data. Even if you stop and restart the container, your to-dos stay saved.
- Save docker-compose.yml.
- Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray.
- Open your terminal in VS Code and make sure you are in the todo-app/ root folder.
- Start the PostgreSQL container in the background by running:
docker compose up -d
- You should see output indicating the database container was created and started.
- Now move into the server directory and start the Express server by running:
cd server
npx tsx src/index.ts
- You should see Server running at http://localhost:3000 printed in your terminal.
What is tsx?
The tsx tool runs TypeScript files directly without a separate compile step. It's perfect for development because you can edit your .ts files and restart the server immediately.
- Open a second terminal tab in VS Code (click the + icon in the terminal panel).
- Test that the API responds by running:
curl http://localhost:3000/api/todos
- You should see [] (an empty JSON array). This confirms the server connected to PostgreSQL, created the todos table, and returned an empty list.
Seeing a connection refused error?
If curl returns a connection error, check these common causes:
- Make sure Docker Desktop is running and the PostgreSQL container started successfully. Run docker ps to confirm a container with postgres:17 is listed.
- Check that you ran docker compose up -d from the todo-app/ root (where docker-compose.yml lives), not from inside server/.
- If the server crashed with a database connection error, wait a few seconds for PostgreSQL to finish starting up, then run npx tsx src/index.ts again.
Help me debug my Docker Compose or Express connection issue.
- Once you've confirmed the [] response, press Ctrl+C in the first terminal tab to stop the Express server. Leave the PostgreSQL container running.
✔️ Awesome, I've got everything!
Great. Your backend is working. Make sure all files are saved.
ⓧ I'd like to double check the full code
Here is the complete docker-compose.yml:
services:
db:
image: postgres:17
restart: always
shm_size: 128mb
ports:
- "5432:5432"
environment:
POSTGRES_USER: todouser
POSTGRES_PASSWORD: todopass
POSTGRES_DB: tododb
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Your backend API is fully functional. Next up, you'll build the React frontend that talks to these endpoints and lets users interact with their to-do list.
Build the React TypeScript Frontend
Your backend API is tested and ready to serve data. Now you need a frontend that actually lets people interact with their to-do list.
In this step, you will scaffold a React app using Vite, replace the template code with a to-do list component, and connect it to your Express API.
In this step, get ready to:
- Scaffold a React TypeScript project with Vite.
- Build a to-do list component with add, toggle, and delete functionality.
- Style the app and test it alongside your running backend.
Scaffold the React app with Vite
Vite is a modern build tool that scaffolds a ready-to-go React project with TypeScript support baked in.
- In VS Code, open a new terminal by pressing Ctrl+` (macOS and Windows).
- Make sure you are in the todo-app root folder. If not, navigate there by running:
cd ~/Desktop/todo-app
- Scaffold the React TypeScript frontend by running this command:
npm create vite@latest client -- --template react-ts
What does this command do?
npm create vite@latest downloads and runs the Vite project scaffolder. client names the folder it creates. The --template react-ts flag tells Vite to use the React + TypeScript template.
- Move into the new client folder and install the dependencies by running:
cd client
npm install
- Confirm the files were installed by running:
ls
- You should see files like package.json, tsconfig.json, vite.config.ts, and a src/ folder in the output.
Command not found or network error?
Make sure you have Node.js v22+ installed by running node --version. Vite requires Node.js version 20.19+ to scaffold projects.
If the command hangs, check your internet connection. The scaffolder downloads the template from the npm registry.
help me troubleshoot why npm create vite is failing
Write the to-do list component
The scaffolded template comes with demo content you do not need. You will replace it with a component that fetches to-dos from your API and handles adding, completing, and deleting tasks.
- Open client/src/App.tsx in the VS Code Explorer sidebar.
- Select all the existing content and replace it with the following code:
import { useState, useEffect } from 'react';
import './App.css';
// Define the shape of a to-do item from the API
interface Todo {
id: number;
text: string;
completed: boolean;
created_at: string;
}
function App() {
// State: the list of to-dos and the current input value
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodo, setNewTodo] = useState('');
// Switch API URL between dev (Vite port) and production (same server)
const API_URL = import.meta.env.DEV ? 'http://localhost:3000/api/todos' : '/api/todos';
// Fetch to-dos from the API when the component first loads
useEffect(() => {
fetchTodos();
}, []);
async function fetchTodos() {
const response = await fetch(API_URL);
const data: Todo[] = await response.json();
setTodos(data);
}
// Handle form submission to add a new to-do
async function addTodo(e: React.FormEvent) {
e.preventDefault();
if (!newTodo.trim()) return;
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newTodo }),
});
const todo: Todo = await response.json();
setTodos([todo, ...todos]);
setNewTodo('');
}
// Toggle a to-do's completed status
async function toggleTodo(id: number) {
const response = await fetch(`${API_URL}/${id}`, {
method: 'PUT',
});
const updated: Todo = await response.json();
setTodos(todos.map(t => t.id === id ? updated : t));
}
// Remove a to-do from the list
async function deleteTodo(id: number) {
await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
setTodos(todos.filter(t => t.id !== id));
}
return (
<div className="app">
<h1>To-Do List</h1>
<form onSubmit={addTodo} className="todo-form">
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a new task..."
className="todo-input"
/>
<button type="submit" className="add-button">Add</button>
</form>
<ul className="todo-list">
{todos.map(todo => (
<li key={todo.id} className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<span onClick={() => toggleTodo(todo.id)} className="todo-text">
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)} className="delete-button">
Delete
</button>
</li>
))}
</ul>
{todos.length === 0 && <p className="empty-message">No tasks yet. Add one above!</p>}
</div>
);
}
export default App;
What does this code do?
- The Todo interface defines the exact shape of data coming from your API. TypeScript uses this to catch mistakes at development time.
- useState holds the to-do list and the text input value. useEffect calls fetchTodos() once when the page loads.
- API_URL checks if the app is running in development mode. During dev, it points to http://localhost:3000 (Express). In production, it uses a relative path since Express serves everything.
- addTodo, toggleTodo, and deleteTodo each call the API with the correct HTTP method, then update the local state so the UI refreshes instantly.
- Save client/src/App.tsx.
- You will not see the result yet since the styles are not in place. The next substep adds them.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
import './App.css';
interface Todo {
id: number;
text: string;
completed: boolean;
created_at: string;
}
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodo, setNewTodo] = useState('');
const API_URL = import.meta.env.DEV ? 'http://localhost:3000/api/todos' : '/api/todos';
useEffect(() => {
fetchTodos();
}, []);
async function fetchTodos() {
const response = await fetch(API_URL);
const data: Todo[] = await response.json();
setTodos(data);
}
async function addTodo(e: React.FormEvent) {
e.preventDefault();
if (!newTodo.trim()) return;
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newTodo }),
});
const todo: Todo = await response.json();
setTodos([todo, ...todos]);
setNewTodo('');
}
async function toggleTodo(id: number) {
const response = await fetch(`${API_URL}/${id}`, {
method: 'PUT',
});
const updated: Todo = await response.json();
setTodos(todos.map(t => t.id === id ? updated : t));
}
async function deleteTodo(id: number) {
await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
setTodos(todos.filter(t => t.id !== id));
}
return (
<div className="app">
<h1>To-Do List</h1>
<form onSubmit={addTodo} className="todo-form">
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a new task..."
className="todo-input"
/>
<button type="submit" className="add-button">Add</button>
</form>
<ul className="todo-list">
{todos.map(todo => (
<li key={todo.id} className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<span onClick={() => toggleTodo(todo.id)} className="todo-text">
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)} className="delete-button">
Delete
</button>
</li>
))}
</ul>
{todos.length === 0 && <p className="empty-message">No tasks yet. Add one above!</p>}
</div>
);
}
export default App;
Style and test the frontend
Your component renders the to-do list, but it needs CSS to look like a proper app. You will also update the base body styles.
- Open client/src/index.css and replace its entire contents with:
body {
margin: 0;
min-height: 100vh;
background-color: #fafafa;
}
- Open client/src/App.css and replace its entire contents with the following styles:
/* Container centers the app and sets a readable max-width */
.app {
max-width: 600px;
margin: 40px auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #333;
}
/* Form layout: input stretches, button stays fixed */
.todo-form {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-input {
flex: 1;
padding: 10px 15px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 16px;
}
.todo-input:focus {
outline: none;
border-color: #646cff;
}
.add-button {
padding: 10px 20px;
background-color: #646cff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.add-button:hover {
background-color: #535bf2;
}
/* List removes default bullet styling */
.todo-list {
list-style: none;
padding: 0;
}
/* Each to-do item is a flex row with text and a delete button */
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
margin-bottom: 8px;
background-color: #f9f9f9;
border-radius: 6px;
border: 1px solid #eee;
}
/* Completed items get a strikethrough */
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-text {
cursor: pointer;
flex: 1;
}
.delete-button {
padding: 5px 10px;
background-color: #ff4444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.delete-button:hover {
background-color: #cc0000;
}
.empty-message {
text-align: center;
color: #999;
font-style: italic;
}
What do these styles do?
- index.css resets the body margin and sets a light background for the full page.
- App.css centers the app at 600px wide, uses flexbox for the form and each to-do item, and adds a strikethrough for completed tasks.
- The .todo-text cursor is set to pointer so users know they can click to toggle completion.
- Save both client/src/index.css and client/src/App.css.
Now test the full frontend by running it alongside your backend. You need three things running: PostgreSQL (already up from earlier), the Express server, and the Vite dev server.
- In your VS Code terminal, make sure you are in the server folder and start the Express backend by running:
cd ~/Desktop/todo-app/server
npx tsx src/index.ts
- You should see Server running at http://localhost:3000 in the terminal output.
- Open a second terminal in VS Code by pressing Ctrl+` again.
- Start the Vite dev server from the client folder by running:
cd ~/Desktop/todo-app/client
npm run dev
- Vite will print a local URL (typically http://localhost:5173). Open that URL in your browser.
- You should see the to-do list app with a heading, an input field, and an Add button. Try adding a task to confirm it connects to your API.
Seeing a blank page or network errors?
If the page loads but adding a to-do fails, confirm your Express server is running on port 3000 in the other terminal. The React app sends requests to http://localhost:3000 during development.
If you see a database connection error in the Express terminal, make sure the PostgreSQL container from the previous step is still running. Run docker ps to check. If it is not listed, run docker compose up -d from the todo-app root folder.
help me debug why my React frontend cannot connect to the Express backend
✔️ Awesome, I've got everything!
Great. Make sure both CSS files are saved and your app is working in the browser.
ⓧ I'd like to double check the full code
body {
margin: 0;
min-height: 100vh;
background-color: #fafafa;
}
.app {
max-width: 600px;
margin: 40px auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #333;
}
.todo-form {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-input {
flex: 1;
padding: 10px 15px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 16px;
}
.todo-input:focus {
outline: none;
border-color: #646cff;
}
.add-button {
padding: 10px 20px;
background-color: #646cff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.add-button:hover {
background-color: #535bf2;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
margin-bottom: 8px;
background-color: #f9f9f9;
border-radius: 6px;
border: 1px solid #eee;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-text {
cursor: pointer;
flex: 1;
}
.delete-button {
padding: 5px 10px;
background-color: #ff4444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.delete-button:hover {
background-color: #cc0000;
}
.empty-message {
text-align: center;
color: #999;
font-style: italic;
}
Your frontend is connected and working. Next up, you will containerize the entire stack into a production-ready Docker image.
Containerize for Production
Your full-stack to-do app runs beautifully in development. The React frontend talks to the Express API, which persists data in PostgreSQL.
Development mode relies on multiple terminal windows and hot-reload tools that don't belong on a production server. In this step, you'll package the entire app into a single Docker image that can run anywhere with one command.
In this step, get ready to:
- Create a multi-stage Dockerfile that builds both frontend and backend into a single production image.
- Update Docker Compose to run the full production stack with one command.
- Test the containerized app locally before deploying to the cloud.
Create the multi-stage Dockerfile
A multi-stage Docker build uses separate stages to compile code, then copies only the final output into a slim production image. This keeps your production container small because it never includes dev tools or source files.
- Create a new file called Dockerfile in the root of your todo-app folder (at the same level as docker-compose.yml).
- Paste in the following multi-stage build configuration:
# Stage 1: Build the React frontend
FROM node:22-alpine AS frontend-build
WORKDIR /app/client
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
# Stage 2: Build the TypeScript backend
FROM node:22-alpine AS backend-build
WORKDIR /app/server
COPY server/package.json server/package-lock.json ./
RUN npm ci
COPY server/ ./
RUN npx tsc
# Stage 3: Production image
FROM node:22-alpine
WORKDIR /app
COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev
COPY --from=backend-build /app/server/dist ./dist
COPY --from=frontend-build /app/client/dist ./public
EXPOSE 3000
CMD ["node", "dist/index.js"]
What does this Dockerfile do?
This Dockerfile uses three stages to produce a minimal production image:
- Stage 1 installs the React dependencies and runs npm run build to produce static HTML, CSS, and JS files.
- Stage 2 installs the server dependencies and runs npx tsc to compile TypeScript into JavaScript.
- Stage 3 is the final image. It installs only production dependencies, then copies the compiled backend and built frontend from the earlier stages. No source code, no dev tools.
Why multi-stage builds?
A single-stage Dockerfile would include all the build tools (TypeScript compiler, Vite, dev dependencies) in the final image. That makes the image large and exposes tools that have no business running in production.
Multi-stage builds let you use heavy toolchains during the build, then throw them away. Your production image only has Node.js and your compiled app.
- Save Dockerfile.
- Confirm the file exists by running:
ls Dockerfile
You should see Dockerfile printed in the output.
Don't see the file?
- Make sure your terminal is in the todo-app root folder, not inside server/ or client/.
- The file must be named exactly Dockerfile with a capital D and no file extension.
Help me find where my Dockerfile went.
Add the production app service to Docker Compose
Docker Compose profiles let you group services by environment. You'll add an app service under a prod profile so it only starts when you explicitly ask for production mode.
- Open docker-compose.yml in VS Code.
- Find the volumes: section at the bottom of the file.
- Add the following app service between the db service and the volumes: section:
app:
profiles: ["prod"]
build: .
restart: always
ports:
- "80:3000"
environment:
DATABASE_URL: postgres://todouser:todopass@db:5432/tododb
NODE_ENV: production
depends_on:
- db
What does this service do?
- profiles: ["prod"] means this service only starts when you pass --profile prod. During development, only the database runs.
- build: . tells Docker Compose to build using the Dockerfile in the current directory.
- ports: "80:3000" maps your machine's port 80 (HTTP default) to the container's port 3000 where Express listens.
- depends_on: db ensures PostgreSQL starts before your app.
- Save docker-compose.yml.
You won't see a change yet. The next substep runs the full production stack.
✔️ Awesome, I've got everything!
Great. Double check you've saved docker-compose.yml before moving on.
ⓧ I'd like to double check the full code
Here's the complete docker-compose.yml with both services:
services:
db:
image: postgres:17
restart: always
shm_size: 128mb
ports:
- "5432:5432"
environment:
POSTGRES_USER: todouser
POSTGRES_PASSWORD: todopass
POSTGRES_DB: tododb
volumes:
- pgdata:/var/lib/postgresql/data
app:
profiles: ["prod"]
build: .
restart: always
ports:
- "80:3000"
environment:
DATABASE_URL: postgres://todouser:todopass@db:5432/tododb
NODE_ENV: production
depends_on:
- db
volumes:
pgdata:
Test the full production stack locally
Before deploying to the cloud, you'll run the production Docker setup on your own machine. This catches configuration issues early.
- If you still have the Vite dev server or backend running in any terminal, press Ctrl+C to stop them.
- Stop any existing containers from earlier by running:
docker compose down
- Build and start the full production stack by running:
docker compose --profile prod up --build
What does this command do?
- --profile prod activates the app service (which has the prod profile) alongside the always-active db service.
- --build forces Docker to rebuild the image using your Dockerfile, even if a cached version exists.
The first build takes a few minutes as Docker downloads the base images and installs dependencies. Subsequent builds are much faster thanks to layer caching.
Wait until you see a log message like Server running at http://localhost:3000 from the app container.
- Open your browser and navigate to http://localhost.
You should see your to-do app. Try adding a task to confirm the frontend, backend, and database all communicate correctly through Docker.
App not loading at localhost?
- Check the terminal output for errors. A common issue is a missing package-lock.json file. If you see an error about it, run npm install inside both server/ and client/ to generate lock files, then rebuild.
- Make sure Docker Desktop is running. The build will fail silently if the Docker daemon is not active.
- If port 80 is already in use, you'll see a bind error. Stop whatever is using port 80, or temporarily change the port mapping in docker-compose.yml to "8080:3000" and visit http://localhost:8080 instead.
Help me debug my Docker Compose production build.
Your app is now fully containerized and running in production mode on your local machine. Next up, you'll launch an AWS EC2 instance and deploy this exact same Docker setup to a real server in the cloud.
Launch an AWS EC2 Instance
Your to-do app works perfectly on your local machine. But right now, only you can see it.
To make the app accessible to anyone with a browser, you need to run it on a server that is always connected to the internet. AWS EC2 gives you a virtual server in the cloud that you can control remotely via SSH.
In this step, get ready to:
- Create an AWS Free Tier account and navigate to the EC2 dashboard.
- Launch a t2.micro instance with Amazon Linux 2023.
- Connect to your instance via SSH.
Sign up for AWS and navigate to EC2
AWS offers a Free Tier that includes 750 hours per month of a t2.micro instance for 12 months. This is more than enough to run your to-do app around the clock without paying anything.
- Open your browser and go to https://aws.amazon.com.
- Click Create an AWS Account (or Sign In if you already have one).
- Follow the sign-up flow. You will need an email address, a password, and a credit card for identity verification.
- Once signed in, use the search bar at the top of the AWS Management Console.
- Type EC2 and select EC2 from the results to open the EC2 dashboard.
What is EC2?
EC2 (Elastic Compute Cloud) is a virtual server in the AWS cloud. You choose the operating system, the hardware size, and the network rules. It works like renting a computer that lives in a data center and stays online 24/7.
Can't find EC2 in the console?
Make sure you are signed in as a root user or an IAM user with EC2 permissions. The search bar at the very top of the page searches all AWS services.
help me find the EC2 dashboard in the AWS Console
Launch a t2.micro instance
Now you will create a virtual server. You will pick the operating system (Amazon Linux 2023), the hardware size (t2.micro, which is free-tier eligible), and the security rules that let you connect.
- On the EC2 dashboard, click Launch instance.
- Under Name and tags, enter todo-app-server in the Name field.
- Under Application and OS Images (Amazon Machine Image), click Quick Start, then select Amazon Linux.
- From the Amazon Machine Image (AMI) dropdown, choose the AMI marked Free Tier eligible (Amazon Linux 2023).
- Under Instance type, select t2.micro (also labeled Free Tier eligible).
- Under Key pair (login), click Create new key pair.
- Enter todo-app-key as the key pair name.
- Leave the key pair type as RSA and the private key file format as .pem.
- Click Create key pair. Your browser will automatically download a file called todo-app-key.pem. Save it somewhere safe on your computer.
Do not lose this file
The .pem file is your only way to connect to the server. AWS does not keep a copy. If you lose it, you cannot SSH into your instance and will have to start over.
Now configure the network settings to allow SSH and HTTP traffic:
- Under Network settings, click Edit.
- Check the box for Allow SSH traffic from and leave the source as Anywhere.
- Check the box for Allow HTTP traffic from the internet.
- Leave all other settings at their defaults.
- Click Launch instance.
What is a security group?
A security group acts as a virtual firewall for your instance. The rules you just configured allow inbound traffic on port 22 (SSH, so you can connect remotely) and port 80 (HTTP, so browsers can reach your app). All other ports are blocked by default.
- On the success screen, click the instance ID link to open the Instances page.
- Wait until the Instance state column shows Running and the Status check column shows 2/2 checks passed. This usually takes 1-2 minutes.
- Select your instance and find the Public IPv4 address in the details panel at the bottom. Copy this address. You will need it in the next substep.
Refresh the page
If the status check still shows Initializing, click the refresh icon at the top of the Instances table. Do not navigate away.
Connect to your instance via SSH
Your server is running in the cloud. Now you will connect to it from your local terminal using SSH and the key pair you just downloaded.
First, set the correct permissions on your key file so SSH will accept it:
macOS
- Open your terminal.
- Navigate to the folder where your todo-app-key.pem file was downloaded (usually ~/Downloads) by running:
cd ~/Downloads
- Set the correct file permissions by running:
chmod 400 todo-app-key.pem
Windows
- Open PowerShell.
- Navigate to the folder where your todo-app-key.pem file was downloaded by running:
cd $HOME\Downloads
- Right-click the todo-app-key.pem file and select Properties.
- Go to the Security tab, click Advanced.
- Click Disable inheritance, then choose Remove all inherited permissions from this object.
- Click Add, then Select a principal. Enter your Windows username and click OK.
- Grant Read permissions only, then click OK and Apply.
Why change permissions?
SSH refuses to use a private key file if other users on your computer can read it. Setting restrictive permissions tells SSH the key is secure.
- Connect to your EC2 instance by running this command (replace the IP address with the Public IPv4 address you copied earlier):
ssh -i todo-app-key.pem ec2-user@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
- When asked "Are you sure you want to continue connecting?", type yes and press Enter.
You should see a welcome message from Amazon Linux and a command prompt that looks like [ec2-user@ip-... ~]$. You are now logged into your cloud server.
Connection timed out or permission denied?
If you see a timeout, make sure your security group allows SSH (port 22) from anywhere. Go back to the EC2 console, select your instance, click the Security tab, and check the inbound rules.
If you see a "permission denied" or "unprotected private key file" error, double-check that you ran the chmod 400 command on the correct .pem file.
help me troubleshoot my SSH connection to EC2
Your virtual server is live and you are connected. Next up, you will install Docker on this server and deploy your to-do app so anyone in the world can use it.
Deploy to EC2
Your app is built, containerized, and tested locally. Now it is time to ship it to the cloud.
In this step, you will install Docker on your EC2 instance, copy your project files over, and run the same docker-compose.yml that worked locally. The result is your to-do app live on the internet.
In this step, get ready to:
- Install Docker and Docker Compose on the EC2 instance.
- Copy your project files to the instance.
- Launch the production stack and access your app from a browser.
Install Docker and Docker Compose on the EC2 instance
Your EC2 instance is a fresh server with no container runtime installed. You need Docker to run containers and the Docker Compose plugin to orchestrate multi-container apps.
- In your SSH session on the EC2 instance, update the system packages by running this command:
sudo yum update -y
- Install Docker by running:
sudo yum install -y docker
- Start the Docker service by running:
sudo service docker start
- Add ec2-user to the docker group so you can run Docker commands without sudo:
sudo usermod -a -G docker ec2-user
- Install the Docker Compose plugin by running:
sudo yum install -y docker-compose-plugin
- Log out and reconnect to your instance so the group permissions take effect. Close your SSH session by running:
exit
- Reconnect via SSH by running this command from your local terminal:
ssh -i [[PEMPATH="path/to/your-key.pem"]] ec2-user@[[EC2_PUBLIC_IP="your EC2 public IP address"]]
- Verify Docker is working by running:
docker compose version
- You should see a version number printed (something like Docker Compose version v2.x.x). This confirms both Docker and the Compose plugin are installed.
Docker command not found?
Make sure you closed and reopened the SSH session after running sudo usermod -a -G docker ec2-user. The group change only applies to new sessions.
If docker compose version still fails, verify the Docker service is running with sudo service docker status.
help me troubleshoot Docker installation on my EC2 instance
Copy your project files to the EC2 instance
Your project files are on your local machine. You need to transfer the entire todo-app directory to the EC2 instance so Docker can build the images there.
- Open a new terminal window on your local machine (keep the SSH session open in the other window).
- Navigate to the parent directory of your todo-app folder (likely your Desktop) by running:
cd ~/Desktop
- Copy the entire project to your EC2 instance by running:
scp -i [[PEMPATH="path/to/your-key.pem"]] -r todo-app ec2-user@[[EC2_PUBLIC_IP="your EC2 public IP address"]]:/home/ec2-user/
What does this command do?
- scp stands for secure copy. It transfers files over SSH.
- -i specifies your private key file for authentication (the same .pem file you use for SSH).
- -r means recursive. It copies the entire directory and all subdirectories.
- The destination /home/ec2-user/ places the folder in the default home directory on your EC2 instance.
- Wait for the transfer to complete. This may take a minute depending on your internet speed.
Excluding node_modules
The node_modules folders are large and not needed on the server (Docker will install dependencies during the build). If the upload is slow, delete todo-app/server/node_modules and todo-app/client/node_modules locally before running scp.
- Switch back to your SSH terminal connected to the EC2 instance.
- Verify the files arrived by running:
ls /home/ec2-user/todo-app
- You should see client, server, docker-compose.yml, and Dockerfile listed in the output.
Permission denied when running scp?
Make sure your .pem file has the correct permissions. Run chmod 400 your-key.pem on your local machine before trying again.
Also check that you are running scp from your local terminal, not from inside the SSH session.
help me fix permission errors when copying files to EC2 with scp
Launch the production stack on EC2
With Docker installed and your files on the server, you can now start the same production stack you tested locally. The docker-compose.yml already defines everything needed.
- In your SSH session, navigate to the project folder by running:
cd /home/ec2-user/todo-app
- Start the production containers by running:
docker compose --profile prod up -d --build
What does this command do?
- --profile prod activates the app service (which is tagged with the prod profile in your compose file). Without this flag, only the database would start.
- -d runs the containers in detached mode (in the background).
- --build builds the Docker image from your Dockerfile before starting it. This compiles TypeScript, bundles React, and creates the production image.
- The build process takes 2-3 minutes on a t2.micro instance. Wait for it to complete.
- Verify the containers are running by running:
docker compose --profile prod ps
- You should see two containers listed with a status of Up: one for db (PostgreSQL) and one for app (your application).
- Open any browser on your computer and navigate to:
http://[[EC2_PUBLIC_IP="your EC2 public IP address"]]
- You should see your to-do app loaded in the browser. Try adding a task to confirm the full stack is working.
Page not loading in the browser?
Check that your security group allows inbound HTTP traffic on port 80. In the AWS Console, go to your instance, click on the **Security** tab, and confirm the inbound rule for port 80 exists.
If the containers exited, check the logs with docker compose --profile prod logs to see what went wrong.
Make sure you are using http:// (not https://) in the browser URL. Your app does not have an SSL certificate.
help me debug why my app is not loading on EC2
Your to-do app is live on the internet. Anyone with your EC2 public IP address can access it from their browser. You just completed the full deployment lifecycle: write code, containerize, ship to the cloud.
Secret mission
Health Check and Security Hardening
Your app is live, but production systems need monitoring and protection. Add a health check endpoint that verifies your database connection, then lock down your EC2 security group so only your computer can SSH in.
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 t2.micro instance is covered by the AWS Free Tier for the first 12 months of your account (750 hours/month). After that period, leaving the instance running incurs compute charges.
Cost warning
If your AWS Free Tier eligibility has expired, a running t2.micro instance incurs hourly charges. A stopped instance does not incur compute charges but may incur small EBS storage charges for its attached volume.
Stop or terminate the instance when you are finished.
Resources you used:
- EC2 instance (t2.micro, Amazon Linux 2023).
- EC2 key pair (.pem file stored locally).
- EC2 security group (inbound rules for SSH and HTTP).
- Local project folder (todo-app/ on your Desktop).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep the app accessible at your EC2 public IP.
- Your EC2 instance continues running and serving your to-do app.
- Your local todo-app/ folder stays on your Desktop.
- Your .pem key file remains on your machine for future SSH access.
Pause - I'll come back to this later
Shut down the EC2 instance to stop compute charges. Your data on the attached EBS volume is preserved so you can restart later.
- Open the EC2 console at https://console.aws.amazon.com/ec2/.
- In the navigation pane, click Instances.
- Select your instance, then choose Instance state, then Stop instance.
- Click Stop when prompted to confirm.
- Keep your local todo-app/ folder and .pem file. You will need both to reconnect later.
How to restart later
Return to the EC2 console, select your stopped instance, choose Instance state, then Start instance. Your public IP address may change after a restart.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent and cannot be undone.
Terminate the EC2 instance:
- Open the EC2 console at https://console.aws.amazon.com/ec2/.
- In the navigation pane, choose Instances.
- Select your instance, then choose Instance state, Terminate (delete) instance.
- Choose Terminate (delete) when prompted for confirmation.
Delete the key pair from AWS:
- In the EC2 navigation pane, choose Key Pairs.
- Select your key pair and choose Actions, Delete.
- In the confirmation field, enter Delete and then choose Delete.
Delete the security group:
- In the EC2 navigation pane, choose Security Groups.
- Select the security group you created for this project, then choose Actions, Delete security groups.
- Enter Delete to confirm, then choose Delete.
Security group not deleting?
A security group cannot be deleted while it is associated with an instance. Make sure you have terminated the instance first and waited for its state to change before attempting to delete the security group.
Delete the local project folder and key file:
macOS
- Delete your local project folder and key file by running:
rm -rf ~/Desktop/todo-app
rm ~/Downloads/*.pem
Windows
- Delete your local project folder and key file by running:
Remove-Item -Recurse -Force ~\Desktop\todo-app
Remove-Item ~\Downloads\*.pem
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a full-stack TypeScript application to the cloud.
You've learned how to:
- Built a complete full-stack application in TypeScript with a React frontend and an Express backend API.
- Used Docker Compose to manage a PostgreSQL database and a production app container with multi-stage builds.
- Deployed to a real AWS EC2 instance accessible from the public internet using SSH and Docker.
- Secret Mission: Added a health check endpoint and hardened the EC2 security group to restrict inbound traffic.
Ready to quiz yourself?