How to deploy a Node.js app from a GitHub repo in 2026
Published 2026-05-08 by tavin
The shortest path from a Node.js repository on GitHub to a live URL is shorter than it has ever been. You no longer need a build server, a registry account, a Helm chart, or a 200-line YAML pipeline to put a small backend on the public internet.
The path most teams should take in 2026 looks like this:
- Connect a Git repository.
- Pick a branch and the port the app listens on.
- Push code.
- The platform builds a container, runs it, and gives you an HTTPS URL.
This post walks through that path on tavin.cloud and explains the tradeoffs that show up at each step.
What “Node.js on GitHub” usually looks like
A typical small Node service has:
- A
package.jsondeclaringstartand the runtime version. - A lockfile (
package-lock.json,bun.lock,pnpm-lock.yaml, oryarn.lock). - A long-lived HTTP listener — Express, Fastify, Hono, NestJS, Next.js custom server, or a hand-rolled
http.createServer. - A handful of environment variables:
PORT,DATABASE_URL, third-party API keys.
The platform job is to read that repo, decide how to build it, run the resulting process with the right env, and route external traffic to the right port.
Why Railpack is usually the right default
Most Node apps do not need a hand-written Dockerfile to deploy. Railpack is an open-source builder that reads the repo, detects the Node version from package.json or .nvmrc, picks the right package manager from the lockfile, installs dependencies, builds, and produces a container image. The output is a normal Linux container — nothing proprietary about the runtime.
For tavin.cloud, Railpack is the default build path. You do not declare a build command for a typical Node app; the builder infers it. This is the same general pattern that Buildpacks popularized for Heroku and that Nixpacks brought to Railway.
The convenience matters because most Node deployments are repetitive operational work: install, build, start. Auto-detection turns that into platform behavior instead of a per-repo configuration burden.
When you actually need a Dockerfile
A Dockerfile becomes useful when your app needs:
- A specific system package (e.g.
libvips,chromium,ffmpeg). - A native binary or a non-Node tool in the runtime.
- A multi-stage build with caching tuned exactly the way you want.
- The same image in dev, CI, and production.
- A runtime the auto-detector cannot infer (custom Node patches, non-standard entry points).
In those cases, drop a Dockerfile at the repo root. tavin.cloud detects the Dockerfile and uses it instead of Railpack. This is the escape hatch principle: auto-detection is the default, explicit control is always available, and the artifact is still a normal container.
A minimal repo-to-deploy walkthrough
Assume an Express app at the repo root:
// server.js
import express from 'express'
const app = express()
app.get('/', (_req, res) => res.send('hello from tavin.cloud'))
app.listen(process.env.PORT || 3000)
package.json:
{
"name": "hello",
"type": "module",
"scripts": { "start": "node server.js" },
"dependencies": { "express": "^4.21.0" },
"engines": { "node": ">=20" }
}
The deploy steps:
- Push the repo to GitHub.
- Sign in at tavin.cloud and connect the repo.
- Pick the branch and the listening port (
3000). - tavin.cloud detects Node, runs
npm install(orbun install,pnpm install,yarn installbased on the lockfile), runsnpm start, and routes a public URL to port 3000.
Subsequent pushes to the selected branch trigger a new build automatically. If the build fails, the previous deployment keeps serving traffic.
The full step-by-step tour with screenshots is in the tavin.cloud quickstart.
Environment variables and secrets
Real apps need configuration. The interesting choices in 2026 are:
- Per-environment env vars. Set in dashboard, CLI, or over MCP. They are injected into the container at runtime, not baked into the image.
- Build-time vs runtime. Most Node apps only need runtime env. Build-time env is for things like Vite/Next/Nuxt apps where the value is bundled into the client.
- Secret rotation. A scoped credential (per-environment, revocable, audited) is more important than the storage technology.
Avoid putting secrets in the repo. Keep DATABASE_URL, STRIPE_SECRET_KEY, and OAuth client secrets in the platform’s env config. Reference them with process.env.X in code.
For services that talk to external APIs, the 12-factor config principle still holds.
Logs, builds, and health checks
A useful Node deployment surface gives you:
- Build logs. Streamed during the build, kept for the lifetime of the deployment so you can diff a working build against a broken one.
- Runtime logs.
stdoutandstderrof the process, structured if you log JSON. - Health checks. A simple
GET /healthzreturning200 OKis usually enough; more complex apps use readiness vs liveness probes under the hood. - Restarts. When the process exits, the platform restarts it. Keep your app crash-only friendly: assume any restart is fine.
This is platform behavior, not application behavior. The Node app stays a Node app.
Custom domains and TLS
Once the app is live on a tavin.app URL, you usually want it on a real domain. The pattern most platforms have converged on:
- Add a domain to the project.
- Add a
CNAMEorArecord in your DNS provider. - The platform issues a TLS certificate via Let’s Encrypt and renews it automatically.
You should never have to handle a private key by hand for a basic public service.
When AI agents enter the picture
Coding agents — Claude Code, Cursor, Codex, and others — change the deployment story for Node apps. The agent can already read your repo, edit code, run tests, and explain a diff. The remaining gap is shipping that code to production.
The naive solution is to give the agent a long-lived platform API key. That works once and ages badly: the key has too much authority and is hard to revoke without breaking the user’s session.
The better path is a narrow deployment surface — exactly what we argued in the deployment-surface post. For tavin.cloud:
- For public repos, an agent can create a deploy intent. The agent gets a status-only token; the user approves in the browser; the platform creates the project and starts the build.
- For authenticated workflows, an agent uses OAuth or a scoped Personal Access Token over the Model Context Protocol. The agent calls explicit tools —
init_from_repo,deploy_project_source,set_env,wait_for_deployment,get_deployment_runtime_logs— and every tool call lands in an audit row.
The Node app does not change. The way agents touch it does.
Choosing a host: rough mental model
Render, Railway, Fly.io, Vercel, and tavin.cloud all deploy Node apps from GitHub. The differences worth caring about:
- Build model. Auto-detection vs Dockerfile-only vs framework-aware (Vercel for Next.js).
- Runtime model. Long-lived container vs serverless function vs hybrid.
- Agent surface. Whether MCP is exposed, what authority it has, and whether deploy intents exist.
- Region and latency. Where the runtime actually lives.
- Pricing shape. Per-second vs per-request vs reserved.
We have side-by-side comparisons for tavin.cloud vs Render, vs Railway, vs Vercel, and vs Fly.io. They are written from our perspective; read them with that bias in mind.
If your app is mostly serverless-shaped (short request handlers, no background work), serverless may still be the right default. If it is service-shaped — long-lived process, queue worker, daemon, ML service, or anything where you would rather think in containers than functions — a repo-to-container PaaS is usually simpler.
A reasonable 2026 default
For a new Node service that you want on a public URL by the end of the day:
- Put it on GitHub.
- Skip the Dockerfile until you actually need it.
- Pick a host that builds from source by default.
- Let the platform handle TLS, logs, restarts, and rollback.
- Issue a scoped credential to your AI agent so it can deploy and observe without inheriting your dashboard session.
That is the path tavin.cloud is built around: Railpack for the common case, Dockerfile for the explicit case, and an agent-safe surface so coding agents and humans can ship the same Node app through the same primitives.
If you want to try it, the quickstart is the shortest version.