Deploying the Express API to Render

Render is a modern cloud hosting platform that deploys Node.js applications from GitHub with minimal configuration. The free tier runs your Express API on a shared machine and automatically redeploys when you push to your chosen branch. The main trade-off on the free tier is cold starts โ€” if no request arrives for 15 minutes, the service spins down and the next request takes 30โ€“60 seconds to wake it up. For a learning project this is acceptable. In this lesson you will connect your GitHub repository to Render, configure the environment variables, verify the deployment, and understand how to diagnose issues from the logs.

Preparing Express for Deployment

// server/package.json โ€” ensure start script is defined
{
  "scripts": {
    "start": "node index.js",     // Render uses this to start the server
    "dev":   "nodemon index.js"
  },
  "engines": {
    "node": ">=20.0.0"            // specify Node.js version
  }
}

// server/index.js โ€” use process.env.PORT (Render assigns it automatically)
const PORT = process.env.PORT || 5000;
server.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Note: Render assigns the PORT environment variable automatically โ€” do not hardcode a port number. Always use process.env.PORT || 5000 so the app works both on Render (where PORT is set by the platform) and locally (where it falls back to 5000). If your app listens on a hardcoded port instead of process.env.PORT, Render cannot route traffic to it and the deployment will fail health checks.
Tip: Add a health check endpoint before deploying โ€” it is the fastest way to verify the service is running correctly. Render uses it to confirm the app is alive. A simple GET /api/health that returns { status: 'ok' } is enough. If the health check endpoint fails, Render marks the service as unhealthy and rolls back to the previous deployment โ€” which is exactly the safety net you want.
Warning: The Render free tier spins down services after 15 minutes of inactivity. When a request arrives after spin-down, the service takes 30โ€“60 seconds to start. This is the most common complaint about Render’s free tier. For a production app with real users, upgrade to the Starter plan ($7/month) which keeps the service always on. For a learning project, the free tier is perfectly acceptable.

Deploying to Render โ€” Step by Step

1. Push server/ to GitHub (ensure package.json, index.js, src/ are committed)

2. Go to https://render.com โ†’ New โ†’ Web Service

3. Connect GitHub repository
   โ†’ Select your mern-blog repository
   โ†’ Root Directory: server (if monorepo) or leave blank

4. Configure the service:
   Name:           mernblog-api
   Runtime:        Node
   Build Command:  npm install
   Start Command:  npm start
   Instance Type:  Free

5. Add environment variables (Environment tab):
   NODE_ENV         = production
   PORT             = (leave blank โ€” Render sets this automatically)
   MONGODB_URI      = mongodb+srv://mernblog-prod:...@cluster.mongodb.net/mernblog
   JWT_SECRET       = [64-char random hex]
   JWT_EXPIRES_IN   = 7d
   CLIENT_URL       = https://mernblog.netlify.app
   SERVER_URL       = https://mernblog-api.onrender.com
   CLOUDINARY_CLOUD_NAME = ...
   CLOUDINARY_API_KEY    = ...
   CLOUDINARY_API_SECRET = ...
   RESEND_API_KEY   = re_...

6. Click "Create Web Service"
   โ†’ Render builds the Docker container and deploys
   โ†’ Watch the deploy logs โ€” look for "Server running on port ..."

7. Test the health check:
   curl https://mernblog-api.onrender.com/api/health
   โ†’ { "status": "ok", "env": "production" }

Adding the Health Check and Compression

// server/index.js โ€” production additions
const compression = require('compression');
const helmet      = require('helmet');

npm install compression helmet

// โ”€โ”€ Security headers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(helmet({
  crossOriginResourcePolicy: { policy: 'cross-origin' }, // allow Cloudinary images
}));

// โ”€โ”€ Gzip compression โ€” reduces response size 60โ€“80% โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(compression());

// โ”€โ”€ Health check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.get('/api/health', (req, res) => {
  res.json({
    status:  'ok',
    env:     process.env.NODE_ENV,
    uptime:  Math.round(process.uptime()),
    timestamp: new Date().toISOString(),
  });
});

Reading Render Logs

Render Dashboard โ†’ Your Service โ†’ Logs tab

Common log messages and their meaning:

โœ“ "Server running on port 10000"
  โ†’ App started successfully โ€” deployment succeeded

โœ— "MongoServerSelectionError: connection timed out"
  โ†’ MONGODB_URI wrong, or Atlas IP allowlist missing Render's IP
  โ†’ Fix: verify MONGODB_URI env var, add Render IP to Atlas allowlist

โœ— "Error: ENOENT: no such file or directory, open '.env'"
  โ†’ .env file not committed (correct! .env should never be committed)
  โ†’ Fix: set all values in Render's Environment tab instead

โœ— "SyntaxError: Unexpected token ..."
  โ†’ Syntax error in your JavaScript โ€” check the specific file/line

โœ— Port already in use
  โ†’ You hardcoded a port โ€” use process.env.PORT instead

Automatic Deploys from GitHub

Render auto-deploys when you push to the connected branch (usually 'main').

Workflow:
  1. Make changes locally
  2. git add . && git commit -m "feat: add new endpoint"
  3. git push origin main
  4. Render detects the push โ†’ starts a new build
  5. Build runs: npm install
  6. If build succeeds: new version deployed
  7. If build fails: previous version stays live

Manual deploy: Render Dashboard โ†’ Service โ†’ Manual Deploy โ†’ Deploy latest commit

Common Mistakes

Mistake 1 โ€” Hardcoding the PORT

โŒ Wrong โ€” app listens on a specific port:

server.listen(5000); // Render assigns a different port โ€” traffic never arrives!

โœ… Correct โ€” use the PORT environment variable:

server.listen(process.env.PORT || 5000); // โœ“

Mistake 2 โ€” Committing node_modules

โŒ Wrong โ€” node_modules committed to the repository:

# .gitignore missing node_modules entry
# โ†’ 300MB of modules pushed to GitHub and pulled by Render every deploy

โœ… Correct โ€” add node_modules to .gitignore. Render runs npm install during build.

Mistake 3 โ€” Missing environment variables on Render

โŒ Wrong โ€” env vars not set on Render, only in local .env file:

JWT_SECRET=undefined โ†’ jwt.sign throws "secretOrPrivateKey must have a value"

โœ… Correct โ€” set every required environment variable in Render’s Environment tab before deploying.

Quick Reference

Task Detail
Build command npm install
Start command npm start
Listen on PORT process.env.PORT || 5000
Health check URL https://your-service.onrender.com/api/health
View logs Render Dashboard โ†’ Service โ†’ Logs
Trigger redeploy Push to connected GitHub branch
Manual redeploy Dashboard โ†’ Manual Deploy

🧠 Test Yourself

Your Express app deployed to Render shows “Service unavailable” after every push. The logs show the app starts but then immediately crashes. What is the first thing you should check?