Render Tutorials
Build and host a full-featured, secure MCP server on Render

Extend the Blueprint and deploy

⏱ 12 min

The template’s render.yaml is great for the unmodified template. For the extended server you need Postgres, OAuth env vars, the Starter plan (to avoid the free-tier cold start), and a Project + Environment wrapper so the web service and database share a private network.

This step is a directed diff on the existing file, plus the second GitHub OAuth App for the prod callback URL.

1. The Blueprint diff

Here’s the exact transformation from the template’s render.yaml to ours. The bones stay the same - type: web, runtime: node, healthCheckPath: /health, autoDeploy: false - and everything else moves to support a Project + Environment wrapper, OAuth env vars, and a managed Postgres.

render.yaml (template)
- services:
- - type: web
- name: my-mcp-server
- runtime: node
- plan: free
- buildCommand: npm install --include=dev && npm run build
- startCommand: npm start
- healthCheckPath: /health
- autoDeploy: false
- envVars:
- - key: MCP_API_TOKEN
- generateValue: true
- - key: NODE_ENV
- value: production
- - key: PORT
- value: "10000"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
render.yaml (extended)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
+ # yaml-language-server: $schema=https://render.com/schema/render.yaml.json
+
+ projects:
+ - name: notes-mcp
+ environments:
+ - name: production
+ services:
+ - type: web
+ name: notes-mcp
+ runtime: node
+ plan: starter
+ buildCommand: npm install --include=dev && npm run build
+ startCommand: npm start
+ healthCheckPath: /health
+ autoDeploy: false
+ envVars:
+ - key: NODE_ENV
+ value: production
+ - key: PORT
+ value: "10000"
+ - key: LOG_LEVEL
+ value: info
+ - key: PUBLIC_URL
+ fromService:
+ type: web
+ name: notes-mcp
+ envVarKey: RENDER_EXTERNAL_URL
+ - key: DATABASE_URL
+ fromDatabase:
+ name: notes-mcp-db
+ property: connectionString
+ - key: JWT_SIGNING_SECRET
+ generateValue: true
+ - key: GITHUB_CLIENT_ID
+ sync: false
+ - key: GITHUB_CLIENT_SECRET
+ sync: false
+
+ databases:
+ - name: notes-mcp-db
+ plan: basic-256mb
+ postgresMajorVersion: "18"

The diff, line by line:

ChangeWhy
Wrap in projects -> environmentsGroups the web service and database under one project so they share private networking. This is the modern Blueprint shape.
plan: starter instead of plan: freeFree spins down after 15 minutes of inactivity. MCP clients time out on the 30-60s cold start; Starter stays warm.
Remove MCP_API_TOKENReplaced by OAuth in step 5. No shared bearer to generate.
Add JWT_SIGNING_SECRET with generateValue: trueRender generates and stores the secret. You never see it; the service does.
Add GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET with sync: falseHoles Render leaves for you to fill via the Render Dashboard. Keeps secrets out of git.
Add PUBLIC_URL via fromService: { envVarKey: RENDER_EXTERNAL_URL }Render assigns the URL on first sync - you can’t hardcode it. fromService keeps the YAML declarative; the value follows whatever Render assigns or you reassign (e.g. a custom domain).
Add DATABASE_URL via fromDatabasePulls the internal connection string from the Postgres resource at sync time. Never copy-pasted, never committed.
Add the databases blockProvisions managed Postgres 18 on the smallest paid plan. The Postgres-on-Render tutorial covers sizing and HA.
LOG_LEVEL: infoDefault for pino. Bump to debug temporarily when debugging a deploy.

The template’s RENDER_EXTERNAL_HOSTNAME injection (used in src/app.ts for allowedHosts) still works automatically - Render injects it for every web service, no Blueprint entry needed.

2. The second GitHub OAuth App

The local OAuth App from step 5 has localhost URLs baked in - it can’t be used for the deployed site. You need a second app pointing at the Render URL.

You don’t know the Render URL yet (it gets assigned on first sync), so the order is:

  1. Sync the Blueprint without the GitHub vars Push render.yaml, then in the Render Dashboard go New -> Blueprint, connect the repo, hit Apply. Render syncs everything except GITHUB_CLIENT_ID/SECRET (those are sync: false placeholders).
  2. Wait for the URL to be assigned After ~30 seconds the web service has a URL like https://notes-mcp-abc1.onrender.com. Copy it.
  3. Create the production GitHub OAuth App At github.com/settings/developers -> New OAuth App. Homepage URL = your Render URL. Authorization callback URL = <your-render-url>/oauth/github/callback.
  4. Paste the credentials into the Render Dashboard On the notes-mcp service -> Environment tab, fill in GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET. Save - Render redeploys automatically.

3. Verify the live server

Watch the second deploy. You should see your structured logs in the Logs tab as JSON, including the boot line:

Render Logs - boot
{"level":"info","time":1718900111234,"port":10000,"msg":"MCP server listening"}

Then probe the public surfaces. Step through these four checks - each one verifies a different load-bearing piece of the deploy.

Verify the live server (no auth)
$RENDER_URL=https://notes-mcp-abc1.onrender.com
$curl -s "$RENDER_URL/health" | jq
{ "status": "ok" }
$curl -s "$RENDER_URL/.well-known/oauth-protected-resource" | jq
{ "resource": "https://notes-mcp-abc1.onrender.com/mcp", "authorization_servers": ["https://notes-mcp-abc1.onrender.com"] }
$curl -s "$RENDER_URL/.well-known/oauth-authorization-server" \ | jq '{ issuer, authorization_endpoint, token_endpoint, registration_endpoint }'
{ "issuer": "https://notes-mcp-abc1.onrender.com", "authorization_endpoint": "https://notes-mcp-abc1.onrender.com/authorize", "token_endpoint": "https://notes-mcp-abc1.onrender.com/token", "registration_endpoint": "https://notes-mcp-abc1.onrender.com/register" }
$curl -i "$RENDER_URL/mcp" -X POST \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
HTTP/2 401 www-authenticate: Bearer resource_metadata="https://notes-mcp-abc1.onrender.com/.well-known/oauth-protected-resource"

That 401 + WWW-Authenticate is the trigger an MCP client uses to discover the OAuth setup. You’ll let the MCP Inspector chew on it in the next step.

4. What just got provisioned

flowchart TB
  subgraph proj["Project: notes-mcp"]
    subgraph env["Environment: production"]
      web["Web service<br/>notes-mcp<br/>(public HTTPS)"]
      db[("Render Postgres<br/>notes-mcp-db<br/>(private network only)")]
    end
  end
  user[User browser]
  client[MCP client]
  gh[GitHub OAuth]

  user --> web
  client --> web
  web -. "private DNS".-> db
  web -. "outbound".-> gh
  • The web service is publicly reachable on a free onrender.com subdomain with TLS issued and auto-renewed.
  • The Postgres database has no public hostname. Only services in the same project can reach it. Even if someone learns the credentials, there’s no public socket to use them against.
  • The internal DNS name for the database is what fromDatabase resolves to - your DATABASE_URL is a postgres:// URL on a *.internal hostname.

That private wiring is the load-bearing piece of “secure.” OAuth on the front door is good; not having a back door to the database is better.

5. About autoDeploy: false

You kept the template’s autoDeploy: false. Render won’t deploy on every push to main until you flip this. Two reasonable strategies:

  1. Leave it off until you have a CI pipeline that runs the template’s vitest tests on every PR. Manually trigger deploys from the Render Dashboard when ready.
  2. Flip to autoDeploy: true once tests gate merges to main. The deploy pipeline becomes: PR → tests → merge → Render builds + deploys → Render preDeployCommand runs (if you add migrations) → health check passes → traffic shifts.

For the tutorial, leave it off so you control the cadence while you’re learning.

`PUBLIC_URL` is wired with `fromService: { envVarKey: RENDER_EXTERNAL_URL }`. What problem does that solve compared to hardcoding the Render URL in `render.yaml`?

What you learned

  • Extended the template's `render.yaml` with Postgres, OAuth env vars, the Project + Environment wrapper, and the Starter plan
  • `fromService: { envVarKey: RENDER_EXTERNAL_URL }` is how you surface the auto-assigned URL under your own env var name
  • `generateValue: true` keeps signing secrets out of git; `sync: false` keeps OAuth client secrets out of git too
  • You need a second GitHub OAuth App for the prod URL - the first deploy will crash-loop until you fill it in (or pre-create against a fixed subdomain)
  • Kept `autoDeploy: false` until you have CI gating merges - flip it on when you trust the pipeline