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.
- 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"
+ # 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:
| Change | Why |
|---|---|
Wrap in projects -> environments | Groups the web service and database under one project so they share private networking. This is the modern Blueprint shape. |
plan: starter instead of plan: free | Free spins down after 15 minutes of inactivity. MCP clients time out on the 30-60s cold start; Starter stays warm. |
Remove MCP_API_TOKEN | Replaced by OAuth in step 5. No shared bearer to generate. |
Add JWT_SIGNING_SECRET with generateValue: true | Render generates and stores the secret. You never see it; the service does. |
Add GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET with sync: false | Holes 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 fromDatabase | Pulls the internal connection string from the Postgres resource at sync time. Never copy-pasted, never committed. |
Add the databases block | Provisions managed Postgres 18 on the smallest paid plan. The Postgres-on-Render tutorial covers sizing and HA. |
LOG_LEVEL: info | Default 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:
- 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 exceptGITHUB_CLIENT_ID/SECRET(those aresync: falseplaceholders). - 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. - 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. - Paste the credentials into the Render Dashboard On the
notes-mcpservice -> Environment tab, fill inGITHUB_CLIENT_IDandGITHUB_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:
{"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.
$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.comsubdomain 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
fromDatabaseresolves to - yourDATABASE_URLis apostgres://URL on a*.internalhostname.
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:
- Leave it off until you have a CI pipeline that runs the template’s
vitesttests on every PR. Manually trigger deploys from the Render Dashboard when ready. - Flip to
autoDeploy: trueonce tests gate merges tomain. The deploy pipeline becomes: PR → tests → merge → Render builds + deploys → RenderpreDeployCommandruns (if you add migrations) → health check passes → traffic shifts.
For the tutorial, leave it off so you control the cadence while you’re learning.
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