Dev Dissection — Week 5: Deployment, GCP App Engine, Vercel & MongoDB Atlas
In Week 4, you secured your app with authentication. This week, you’re going live. We’ll deploy your backend to Google Cloud App Engine, your frontend to Vercel, and migrate your database to MongoDB Atlas — all production-ready with environment variables configured correctly.
Prerequisites
Before you start you need week 4 following:
- Backend completed (Express + MongoDB + Auth)
- Frontend completed (Next.js)
- Basic CLI and Git knowledge
Part 1: Setting up MongoDB Atlas
- Go to MongoDB Atlas
- Create a free cluster, Select M0/free version in creation

- Whitelist IP’s by providing
0.0.0.0
under Network access - After Cluster is deployed, you’ll need to create a database inside that cluster, Create a
todo
database inside your cluster by browsing collection and selecting add my own data

- After Database is created, in order to access it, you’ll need to create a user with limited permission that can only do read/write to that specific database. Go to Database access and create a user

- You connect via CLI or Mongodb Compass. Your URL would look like this ( I’ve hidden mine for security reasons )
mongodb+srv://server:*****@cluster0.****.****.****/todo?retryWrites=true&w=majority&appName=Cluster0
Part 2: Deploying Backend with proper env setup
We’ll use dotenv
and cross-env
for managing separate .env
files.
- Install dependencies:
npm install dotenv cross-env
- Add
.env
files:
.env.development
MONGO_URI="mongodb://localhost:27017/todos-dev"
JWT_SECRET = 'your-dev-secret-key';
PORT=4000
NODE_ENV=development
.env.production
MONGO_URI="mongodb+srv://server:****@***/todo?retryWrites=true&w=majority&appName=Cluster0"
JWT_SECRET="[add a secret for production]"
PORT=8080
NODE_ENV=production
- Create
env.d.ts
to make sure types are synced from envs
declare namespace NodeJS {
interface ProcessEnv {
MONGO_URI: string;
PORT: string;
JWT_SECRET: string;
NODE_ENV: 'development' | 'production'
}
}
- Modify jwt.ts and index.ts to use process.env.PORT & process.env.JWT_SECRET
const JWT_SECRET = process.env.JWT_SECRET!; // inside jwt.ts
const PORT = process.env.PORT || 4000; // inside index.ts
- Create
loadEnv.ts
to safely load variables and import it at top of index.ts (it should run before API app runs):
import * as dotenv from 'dotenv';
import * as path from 'path';
export type EnvKeys = 'MONGO_URI' | 'JWT_SECRET' | 'PORT';
const envFile =
process.env.NODE_ENV === 'production'
? '.env.production'
: '.env.development';
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
const requiredEnvVars: EnvKeys[] = ['MONGO_URI', 'PORT', 'JWT_SECRET'];
for (const varName of requiredEnvVars) {
if (!process.env[varName]) {
throw new Error(`Missing required environment variable: ${varName}`);
}
}
if (
process.env.NODE_ENV !== 'development' &&
process.env.NODE_ENV !== 'production'
) {
throw new Error("Invalid NODE_ENV. Must be 'development' or 'production'.");
}
- Your index.ts should look like this
import './loadEnv';
import cors from 'cors';
import express, { Response } from 'express';
import mongoose from 'mongoose';
import { authenticate, AuthRequest } from './middleware/auth';
import { authRouter } from './routes/auth';
const app = express();
const PORT = process.env.PORT || 4000;
app.use(cors());
app.use(express.json());
// Connect to MongoDB
mongoose
.connect(process.env.MONGO_URI!)
.then(() => console.log('Connected to MongoDB', process.env.MONGO_URI!))
.catch((err) => console.error('MongoDB connection error:', err));
// Auth routes (public)
app.use('/auth', authRouter);
const todoSchema = new mongoose.Schema(
{
task: { type: String, required: true },
completed: { type: Boolean, default: false },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
},
{ timestamps: true },
);
const Todo = mongoose.model('Todo', todoSchema);
// Protected todo routes
app.post('/todos', authenticate, async (req: AuthRequest, res: Response) => {
try {
const task = req.body.task;
if (!task) {
res.status(400).json({ error: 'Task is required' });
return;
}
const newTodo = await Todo.create({
task: task,
user: req.user!.userId,
});
res.status(201).json(newTodo);
} catch (err) {
res.status(400).json({ error: 'Failed to create TODO' });
}
});
app.get('/todos', authenticate, async (req: AuthRequest, res: Response) => {
const todos = await Todo.find({ user: req.user!.userId });
res.status(200).json(todos);
});
app.put('/todos/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
res.status(400).json({ error: 'Invalid ID format' });
return;
}
const todo = await Todo.findOne({
_id: req.params.id,
user: req.user!.userId,
});
if (!todo) {
res.status(404).json({ error: 'Todo not found' });
return;
}
const updated = await Todo.findByIdAndUpdate(req.params.id, req.body, {
new: true,
});
res.json(updated);
} catch (err) {
res.status(400).json({ error: 'Failed to update TODO' });
}
});
app.delete(
'/todos/:id',
authenticate,
async (req: AuthRequest, res: Response) => {
try {
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
res.status(400).json({ error: 'Invalid ID format' });
return;
}
const deleted = await Todo.findOneAndDelete({
_id: req.params.id,
user: req.user!.userId,
});
if (!deleted) {
res.status(404).json({ error: 'Todo not found' });
return;
}
res.status(204).send();
} catch (err) {
res.status(400).json({ error: 'Failed to delete TODO' });
}
},
);
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
- Update your
package.json
:
{
"name": "backend-api",
"version": "1.0.0",
"main": "src/index.ts",
"scripts": {
"start": "cross-env NODE_ENV=production node dist/index.js",
"dev": "cross-env NODE_ENV=development ts-node-dev --respawn src/index.ts",
"build": "tsc",
"gcp-build": "npm run build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"dotenv": "^16.6.1",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.15.1"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.2",
"@types/jsonwebtoken": "^9.0.10",
"@types/mongoose": "^5.11.96",
"@types/node": "^22.15.29",
"ts-node-dev": "^2.0.0",
"typescript": "^5.8.3"
}
}
Test out your production before deploying
- Run the code, First let’s build it. It would compile to
ts
code intojs
code for production.
npm run build
- Now let’s run it. If it runs successfully then this is how it would run in production (connects to production DB via .env.production)
npm run start
Now your local server connects to production database. Let’s deploy your app to App engine.
GCloud Setup & deployment to GAE
- Go to Google Cloud Platform, Create an account with Billing linked.
We will deploy our server for free, but GCP still needs bank card info for Google App Engine to work. - Create a new Project todo ( or any name ) on GCP
- Select your project, go to App engine and create application. You can select closest location to get lowest ping. For Identity and API Access, leave it empty ( it auto creates )
- Install GCP SDK CLI & setup, run these commands one by one to link CLI with your project:
gcloud init
gcloud auth login
gcloud config set project ******* (replace "*" with your project id)
gcloud auth application-default login
gcloud auth application-default set-quota-project *******
- Create
app.yaml
for GAE on your backend folder:
service: default
runtime: nodejs20
env: standard
instance_class: F1
env_variables:
NODE_ENV: production
PORT: 8080
automatic_scaling:
min_instances: 1
max_instances: 2
target_cpu_utilization: 0.75
handlers:
- url: /.*
script: auto
secure: always
redirect_http_response_code: 301
Note: We’re using F1 instance class, GCP gives free hours every month for F1 instance and we’ve provided auto scaling properties so if we get more traffic, it would spin up another F1 instance to handle traffic. That’s why I love App engine, the autoscaling feature handles your traffic automatically and scales down when traffic is less. Less DevOps on your side.
- Create
.gcloudignore
since we don’t want to upload everything. App engine will automatically install dependencies/node_modules and run the server.
.git/
.github/
.vscode/
README.md
*.tsbuildinfo
.env.development
.prettierrc
.DS_Store
# gcloudignore doesn't respect .gcloudignore files in subdirectories
build/
tls/
node_modules/
dist/
logs/
# Exclude packages not needed for deployment
- Deploy:
gcloud app deploy --verbosity=info
I’m using --verbosity=info
to see what get’s uploaded, status and console logs of deployment. It helps with debugging
Errors you’ll face:
For first time deployment, you’ll get bucket permission issue. That means the autogenerated service account of GAE doesn’t have permission to bucket where code gets uploaded. E.g
ERROR: (gcloud.app.deploy) Error Response: [13] Failed to create cloud build: com.google.net.rpc3.client.RpcClientException: <eye3 title='/ArgoAdminNoCloudAudit.CreateBuild, FAILED_PRECONDITION'/> APPLICATION_ERROR;google.devtools.cloudbuild.v1/ArgoAdminNoCloudAudit.CreateBuild;invalid bucket "staging.todo-***.appspot.com"; service account todo-***@appspot.gserviceaccount.com does not have access to the bucket;AppErrorCode=9;StartTimeMs=1751792556380;unknown;ResFormat=uncompressed;ServerTimeSec=1.072546516;LogBytes=256;Non-FailFast;EndUserCredsRequested;EffSecLevel=privacy_and_integrity;ReqFormat=uncompressed;ReqID=bb1360126578a46a;GlobalID=0;Server=[2002:a65:99e5:0:b0:77:2dc7:6353]:4001.
- Let’s add service account permission (todo-***@appspot.gserviceaccount.com) to write to bucket above (staging.todo-***.appspot.com). Please note your service account and bucket
- Open bucket and on permission tab, add the your service account to bucket with Storage bucket writer/owner permission
Let’s redeploy it with same command.
Note: You can use the same command to deploy new changes of server to GAE
gcloud app deploy --verbosity=info
After successful deployment, you’ll see something like this
Updating service [default]...done.
Setting traffic split for service [default]...done.
INFO: Previous default version [todo-465108/default/20250706t140833] is an automatically scaled standard environment app, so not stopping it.
Deployed service [default] to [https://todo-***.el.r.appspot.com]
You can stream logs from the command line by running:
$ gcloud app logs tail -s default
To view your application in the web browser run:
$ gcloud app browse
INFO: Display format: "none"
Open up the URL and you’ll see CANNOT /GET
that’s because we never made /
GET
api route. We have /auth and /todos
Note: If it says cannot access etc. Open up logs explorer to see where things went wrong. Your logs should be like this (Click Dashboard
App engine sidebar, View error reporting
, select logs explorer
from side bar)

Backend is up and running at the URL. Now we have to provide this when we’re deploying out frontend
Part 3: Deploy Frontend to Vercel
- Create .env on your NextJS FE and provide this
NEXT_PUBLIC_API_URL=http://localhost:4000
- Now update your
lib/api.ts
to use env API URL
import type { Todo, CreateTodoRequest, UpdateTodoRequest } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
// Auth response types
interface AuthResponse {
token: string;
user: {
id: string;
email: string;
name: string;
};
message: string;
}
export class TodoAPI {
private static getAuthHeaders() {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Auth methods
static async register(
email: string,
password: string,
name: string
): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Registration failed');
}
return response.json();
}
static async login(email: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Login failed');
}
return response.json();
}
static async getTodos(): Promise<Todo[]> {
const response = await fetch(`${API_URL}/todos`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) throw new Error('Failed to fetch todos');
return response.json();
}
static async createTodo(data: CreateTodoRequest): Promise<Todo> {
const response = await fetch(`${API_URL}/todos`, {
method: 'POST',
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to create todo');
return response.json();
}
static async toggleTodo(id: string, data: UpdateTodoRequest): Promise<Todo> {
const response = await fetch(`${API_URL}/todos/${id}`, {
method: 'PUT',
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update todo');
return response.json();
}
static async deleteTodo(id: string): Promise<void> {
const response = await fetch(`${API_URL}/todos/${id}`, {
method: 'DELETE',
headers: this.getAuthHeaders(),
});
if (!response.ok) throw new Error('Failed to delete todo');
}
}
- Push your frontend to GitHub
- Go to vercel.com
- Import your GitHub repo
- Add the following Environment Variables:
NEXT_PUBLIC_API_URL = your GCP deployed backend URL (production url e.g https://todo-*****.el.r.appspot.com)
- Click “Deploy”
Now your Frontend is up and running with your backend deployed for free on App engine. Share it with your frontend URL with your friends to test it out.

What You’ve Learned
- Environment-specific config with
.env
+cross-env
- Created a production-safe
loadEnv.ts
for backend (you can do something similar for frontend) - Deployed backend to Google Cloud App Engine
- Deployed MongoDB to MongoDB Atlas
- Deployed frontend to Vercel
Your TODO app is now LIVE and secure.
Note: If you’re having issues with deployment, you can always join our discord server to ask for help or any questions you have in mind
Next Up: Testing APIs & UI
Your app is live — but how do you know it works as expected every time?
Next week, we’ll dive into the world of testing:
- What is testing and why it matters
- Write your first test for the backend API
- Test your frontend UI components
- Automate your checks before deployment
You’ll have confidence in your app’s behavior — and sleep better knowing you won’t break things by accident.
Coming Soon: “If it’s not tested, it’s broken.” Let’s fix that.