RE:NODE

Pre-flight check: catch a broken GitHub deploy before it runs

By RE:NODE Team
16 min read


Most deploys that fail do not fail at the deploy. The code is fetched, the dependencies install, the container starts, and then the app exits because there is no start script, or it listens on port 3000 where nothing outside can reach it, or it looks for a .next folder that nobody built. Every one of those mistakes is already visible in the repository before a single byte is deployed. The pre-flight check on the panel's GitHub tab reads the repository, compares it with how the server will actually boot it, and lists what will break, worst first, with a fix you can copy.

This post covers what the check reads, how to read the card, every class of problem it finds with a real example of the config that triggers it, and how to fix each one. It also covers what it cannot see, because a green card is not a promise that the app works. Most of the fixes apply on any host: a hard-coded port or a localhost bind breaks a deploy everywhere, not only on RE:NODE.

What the check reads, and what it never touches#

The check works from two things: one recursive tree listing of the branch, and a short list of small files fetched by name. The tree listing is paths and sizes only, no content. The files it may read are the ones that decide how the app boots: package.json, deno.json or deno.jsonc, go.mod and the Go files of the main package, requirements.txt or pyproject.toml, .nvmrc, .env.example, and the entry file the server would run. It never clones the repository, and it skips any file over 256 KB, because nothing worth checking at boot is that large.

It runs in two places. When you pick a repository and branch on the GitHub tab, it runs before the link is saved, so you see the problems before anything is deployed. After that, it runs again on every visit to the tab. Results are cached per commit for ten minutes, but the branch head is asked every time, so a push you just made is noticed at once and the cache only saves re-reading files that have not changed.

Three things it deliberately does not do:

  • It never opens a `.env`. A committed .env is reported from its file name alone. Its contents are never fetched, so nothing in it can end up in a finding.
  • It never quotes a file back to you. The output is a list of findings - sentences written by the panel, quoting at most a script or a variable name. Every string passes through the same redactor the AI features use, so a start script with a password inlined in it is quoted with the password withheld.
  • It never fails loudly. GitHub down, a rate limit, the GitHub App uninstalled, a branch that does not exist yet: each becomes a single note saying the check could not run. A check that took the whole tab down with it would be worse than no check.
read onlyyou fix, then linkGitHub repositorybranch headPre-flight checktree + a few filesPre-flight cardworst firstLink savedServer startpull, install, boot
Where the pre-flight check sits in a GitHub deploy

The check follows the same boot logic the server uses. It does not have a separate idea of what a Node or Go app should look like; it reads the list of entry files the startup script falls back to and the variable the Startup tab uses for each runtime. When the boot changes, the check changes with it.

Reading the pre-flight card#

The card sits on the GitHub tab under the heading "Pre-flight check", with a line saying which commit and branch it read. Each finding has one of four levels, and the list is sorted with the worst at the top:

LevelLabel on the cardWhat it means
failWill breakThe app will not start, or will start and be unreachable
warnWorth fixingIt will run, badly or not as intended
infoGood to knowContext, or something the check could not decide
passFineThis part matches how the server boots

Every finding has a title, a sentence of detail, and - where there is one - a fix in a small code box with a Copy button. The fix is written for this server: when it says $SERVER_PORT, that variable really is set at boot. The Re-run button runs the check again on demand.

What the check tests depends on the server's runtime. Node.js, Bun, Deno, Go and Python servers get the full set: start command, port and host, build, version. Web hosting gets the index-file and build checks. Other server types, such as Java, .NET or Rust, run their own build and start command, so they get only the secrets checks, and the card says so rather than staying quiet.

Nothing to start: start scripts and entry files#

The most common failure is an app with nothing for the server to run. The container starts, finds no command, and exits within a second. The console shows a short burst and then nothing, which is confusing if you are not expecting it.

For a Node.js server, the boot runs npm install and then npm run start. If package.json has no start script, it falls back to the first entry file it finds in a fixed list: index.js, server.js, app.js, main.js, bot.js, index.mjs, index.cjs, the same names under src/, and then the TypeScript versions such as index.ts and src/server.ts (run with ts-node). If neither exists, the card shows "No start script and no entry file" at fail level.

The package.json that triggers it usually looks like this - a project that has only ever been run with npm run dev on a laptop:

package.json (before)
{  "name": "my-api",  "main": "src/api.js",  "scripts": {    "dev": "nodemon src/api.js"  },  "dependencies": { "express": "^4.19.2" }}

src/api.js is not one of the fallback names, and there is no start. The fix is one line:

package.json (after)
{  "scripts": {    "dev": "nodemon src/api.js",    "start": "node src/api.js"  }}

A package.json that does not parse is reported on its own, before anything else, because npm refuses it and nothing after that point matters. The fix the card offers is to run npm install locally and read the error. A trailing comma is the usual culprit.

The other runtimes have their own versions of the same check:

  • Bun runs bun install and then bun run start if package.json has a start script, otherwise the entry file named on the Startup tab, otherwise the first file from the same fallback list. If the Startup tab names a file the branch does not have, but a fallback exists, the card tells you which file will actually run and suggests setting the Startup tab to match.
  • Deno runs deno task start from deno.json or deno.jsonc. With no start task, it runs the Startup tab's entry file with deno run -A - which grants every permission, and the card says so. The fallback names are main.ts, mod.ts, server.ts, index.ts, main.js, src/main.ts and src/index.ts. A package.json without a deno.json gets a note: nothing runs npm install on a Deno server, so either let Deno read the dependencies itself or use npm: specifiers.
  • Python runs the entry file named on the Startup tab. If none is named, the panel looks for main.py, bot.py, app.py, run.py and __main__.py in that order and points the Startup tab at the first one when you link. If the Startup tab names server.py and the branch has no such file, that is a fail, with the fix naming the candidate it did find.
  • Go builds the package on the Startup tab (GO_MAIN, . by default) with go build, and a failed build stops the start. The check wants a go.mod, Go files in that folder, package main, and a func main(). A folder holding a library package is reported as such, because go build on a library produces no program.

The Go case catches a layout that trips up a lot of people: the module root holds a library package and the program lives in cmd/server. Set GO_MAIN on the Startup tab to ./cmd/server and the finding turns green. If there is no go.mod at all, run go mod init with your module name, then go mod tidy, and commit both go.mod and go.sum.

Wrong port, wrong host#

The second most common failure is an app that starts fine and cannot be reached. It is the most frustrating, because the console says "listening" and the browser says nothing.

Only the server's own port is reachable from outside. The panel sets two environment variables at boot, PORT and SERVER_PORT, to that port. An app that hard-codes 3000, 5000, 8000 or 8080 is listening on a port that nothing routes to, unless your allocation happens to be that number. And an app bound to localhost or 127.0.0.1 is reachable only from inside its own container, whatever port it uses. For a fuller explanation of why, see game server ports explained - the same rules apply to apps.

The check reads the start script first and then the entry file, and reports:

  • a start script that fixes the port, such as next start -p 3000 or --port 8080 (warn);
  • an entry file that calls .listen(3000) or sets port = 5000 (warn);
  • an entry file that binds localhost or 127.0.0.1 (warn);
  • an entry file that reads process.env.PORT, SERVER_PORT, Bun.env.PORT or Deno.env.get("PORT") (pass);
  • no web server found at all (info) - fine for a Discord or Telegram bot, worth a second look if you expected HTTP.

This Express entry file triggers two findings at once, the hard-coded port and the localhost bind:

server.js (before)
const express = require('express');const app = express();app.get('/', (req, res) => res.send('ok'));app.listen(3000, 'localhost', () => console.log('listening on 3000'));

The fix the card suggests:

server.js (after)
const port = Number(process.env.PORT);app.listen(port, '0.0.0.0', () => console.log(`listening on ${port}`));

The same idea in the other runtimes. Python: read the port with int(os.environ.get("SERVER_PORT", os.environ.get("PORT", 8080))) and pass host="0.0.0.0". Go: http.ListenAndServe(":"+os.Getenv("PORT"), mux) - a bare colon listens on every interface, and a "localhost:8080" string is reported. Bun: Bun.serve({ port: Number(process.env.PORT), hostname: '0.0.0.0', fetch }).

Deno has a special case worth knowing. Deno.serve(handler) with no port listens on 8000, which is not your allocation. A generic check would only say it could not tell which port is in use; this one names the problem:

main.ts
Deno.serve(  { port: Number(Deno.env.get("PORT")), hostname: "0.0.0.0" },  (_req) => new Response("ok"),);

Sometimes the card says "Could not tell which port" at info level. That happens when the server is created somewhere the check does not read, such as a file imported from the entry file. It is not an error; open the file that calls listen and check it by eye.

Builds that never ran#

Frameworks with a build step fail in a particular way: the start command looks for build output that does not exist. The check knows four of them and what each one produces - Next.js builds .next, Nuxt builds .output, Angular and Vite build dist. If the project depends on one of these, it asks three questions: does the start script, prestart or postinstall run a build; if not, is the build output committed; and if neither, it is a fail.

A typical Next.js package.json that fails:

package.json (before)
{  "scripts": {    "dev": "next dev",    "build": "next build",    "start": "next start"  }}

build exists, but nothing calls it on the server. The fix the card gives builds and then serves on the right host and port:

package.json (after)
{  "scripts": {    "build": "next build",    "start": "next build && next start -H 0.0.0.0 -p $SERVER_PORT"  }}

The alternatives are a postinstall that runs the build, or committing the build output. Committing .next works and the card passes it, with a reminder that you then have to rebuild before every push. Most people forget within a week. We would rather build on the server.

A start script that runs a development server is a separate warning. next dev, nuxt dev, ng serve and a bare vite are slow, unminified, and often bound to localhost. For Vite, the suggested fix is vite build && vite preview --host 0.0.0.0 --port $SERVER_PORT; for Nuxt, nuxt build && node .output/server/index.mjs. The Next.js deploy guide covers the memory side of building on a small server.

Plain TypeScript gets the same treatment without a framework. If typescript is a dependency and the start script runs node dist/index.js, but dist/index.js is not committed and nothing runs tsc before the start, the card reports "TypeScript is never compiled" and suggests tsc && node dist/index.js.

Runtime version mismatches#

A project written for one major version of a runtime can fail to install or run on another, usually on a native module or a syntax change. The check compares what the repository asks for with what the server runs.

For Node.js it reads .nvmrc first and then engines.node in package.json. .nvmrc containing 20 on a server running Node 22 is a warning. With engines, a >=18 range passes on anything from 18 up; a ^20 or a bare 20 passes only on Node 20. The fix offered is either to pick the matching version under Runtime version on the same tab, or to update the file. Choose by which is true: if the project really needs Node 20, change the server; if .nvmrc is a leftover from two years ago, change the file.

.nvmrc
22

For Go, it reads the go line in go.mod. If go.mod asks for a newer Go than the server has, the build either downloads the newer toolchain before every start, which needs network and disk each time, or refuses to build. Pick that Go version or newer under Runtime version, or lower the go line if the code does not actually need it.

The version check only runs when the server's image names a version the check can read. If it cannot tell, it stays silent rather than guessing.

Python, web hosting and other runtime specifics#

Python has two findings that catch people coming from other platforms.

The server installs requirements.txt with pip before the start. A project that declares its dependencies only in pyproject.toml gets a warning, because nothing in it is installed. Export a requirements file and commit it:

bash
$ pip freeze > requirements.txt$ uv pip compile pyproject.toml -o requirements.txt

Either command works; the second keeps the file to what pyproject.toml declares rather than everything in your local environment. The Python requirements guide goes further into pinning.

A Procfile is reported as not read. The server runs the entry file on the Startup tab, not the command in a Procfile. If your Procfile says web: gunicorn app:app, that command is ignored, so make sure the entry file starts the server itself.

Web hosting is simpler and stricter. The host serves the folder as it is and never runs npm run build. The check wants an index.html, index.php or index.htm at the root. If it finds none but sees dist/index.html, build/index.html, out/index.html, public/index.html or _site/index.html, it says so and suggests committing the built files or pointing the deploy path at that folder. A project with a build script and no built output committed gets a separate warning for the same reason. See the static site hosting guide for the build side.

Secrets: a committed .env and .env.example#

This is the only finding that is about safety rather than whether the app starts, and it runs for every server type.

Any file named .env, .env.local, .env.production, .env.prod, .env.development or .env.dev, anywhere in the tree, is a fail: "A .env is committed to the repository". Every password and key in it is readable by anyone who can read the repository, in every commit since it was added. On web hosting it is worse: the file is served with the rest of the folder, so it can be downloaded from the site itself.

The fix, in order:

bash
$ git rm --cached .env$ echo ".env" >> .gitignore$ git commit -m "Stop tracking .env"$ git push

Then rotate the credentials - database passwords, API tokens, bot tokens - and create the new .env on the server through the Files tab. A .env is never fetched from GitHub, so the server only ever has the one you put there. Environment variables and secrets covers the wider practice.

A .env.example gets an info finding that lists the keys it declares, up to thirty, so you can see at a glance what the server's .env needs. Values in the example are not shown. If a project uses dotenv or python-dotenv and has no .env.example, the card notes that the app reads a .env and reminds you to upload one.

What the pre-flight check cannot catch#

The check reads files. It does not run anything, and that sets its limits.

  • Runtime errors. A missing environment variable that crashes the app on the first request, a database that refuses the connection, a typo in a route - none of these are visible in a file listing.
  • Memory. A Next.js build that needs more RAM than the plan has will pass the check and fail at build time. Node memory limits explains how to tell.
  • Indirect ports. A port read from a config file or set in an imported module may show as "Could not tell". Check it by hand.
  • Private dependencies. A package from a private registry needs credentials at install time; the check does not test installs.
  • Anything outside the branch. Uploaded files, the server's own .env, and database contents are not part of the repository.

A green card means the boot will find something to run, on the right port, built and on a matching version. That removes the boring failures, so what is left in the console is a real bug.

When the console does show a crash, the "Work on this server with AI" bar on the Console tab has a "Server won't start" option that copies a ready-made prompt with the console tail. The pre-flight findings travel into the AI context too, so an assistant asked to fix the repository starts from what is already known to be wrong. The GitHub deploy guide covers linking and deploy on push, and the deploys section of our guides covers the switches.

FAQ#

Does the pre-flight check block a deploy?

No. It reports; it does not stop you. You can link a repository with fail-level findings, and the deploy will go ahead and fail the way the card predicted. The point is to see it before rather than after.

Why does the card say a file is missing when it is in my repository?

Three usual reasons: the file is on a different branch from the one linked, the deploy path points at a subfolder that does not contain it, or the repository is large enough that GitHub returned a partial file list, which the card flags at the top. Check the branch name in the card's header line first.

I fixed the problem and pushed. Why does the card still show it?

It should not for long. The branch head is checked on every visit, so a new commit triggers a fresh read. If it still shows, press Re-run, and check that you pushed to the branch the server is linked to rather than to another one.

Can the check see the values in my .env?

No. A committed .env is reported by its name and its contents are never requested. The .env.example is read, but only its key names appear on the card. Every string on the card also passes through the redactor before it is shown.

Which runtimes does the check cover?

Node.js, Bun, Deno, Go and Python get the start, port, build and version checks; web hosting gets the index-file and build checks. Other server types get the committed-secrets checks and a note that runtime checks do not apply.

Does it work with GitLab or Bitbucket?

No. Git deploy on RE:NODE is GitHub only, through a GitHub App, and the check reads the repository through that App's access - so private repositories work, as long as the App can see them.

Was this helpful?