RE:NODE

App hosting12 min read

Deploy a Node.js app from GitHub, and redeploy on push

From an empty server to a running Node app: the start command, the port, environment variables, a domain in front, and automatic redeploys on push.

Updated

2 readers

The whole point of application hosting is that you should never upload a zip file. A Node app deploys from a GitHub repository in three settings: which repository and branch, what command starts it, and whether a push to that branch should redeploy. Everything else in this post is the detail around those three - what the repository has to look like before it will work, which port to bind to, where the secrets go, and the eight things that break on somebody's first deploy.

This is written for a container-based host with a panel, which is what most people mean by application hosting now. The same shape applies on a plain VDS with a systemd unit and a webhook, and it is a different job entirely from a build pipeline that produces a container image - there is no image here, just your source and a command.

What deploying from Git actually does#

A Git deploy is a clone, or a pull, into the server's own filesystem, followed by the start command. That is the entire mechanism, and understanding it removes most of the confusion.

What follows from that:

  • There is no build server. If your project needs a build step, it runs on the same container that serves the traffic, using the same memory and the same CPU share. A TypeScript or Next.js build on a 1 GB plan is the single most common reason a first deploy dies.
  • The files on the server are a working copy. Anything written at runtime into the repository directory - uploads, a SQLite file, generated config - is at risk from the next pull. Keep runtime data outside the checkout, or at least outside anything Git tracks.
  • Your branch is your deployed state. There is no separate "release". Rolling back means pushing a revert, or pointing the server at a different branch and restarting.
  • A private repository needs an authenticated pull. That is what the GitHub App exists for, and it is why pasting a personal access token into a config file is the wrong answer.

If you want a separate place to test that shape before it reaches players or customers, a second cheap server on the same account pointed at a staging branch does the job. Staging and production on one account covers how to keep the two from drifting.

Getting the repository ready#

Most failed first deploys are repository problems, not host problems. Five things to check before you connect anything.

Commit your lockfile. package-lock.json belongs in the repository. Without it there is no reproducible install and npm ci cannot run at all.

Ignore `node_modules`. It is rebuilt on the server, it is enormous, and a committed copy built on Windows or macOS will contain native binaries that do not run on Linux.

Have a real start script, and make sure it starts the built output rather than a dev server. next dev, nodemon and ts-node are not production commands: they watch the filesystem, they use more memory, and some of them will not survive a restart cleanly.

Declare your Node version. The engines field is documentation for you as much as for the installer, and it is the first thing to check when something works locally and not on the server.

Keep `.env` out. Add .env to .gitignore and commit a .env.example with the keys and no values, so the next person knows what to fill in.

package.json
{  "name": "example-api",  "private": true,  "type": "module",  "engines": { "node": ">=20" },  "scripts": {    "build": "tsc -p tsconfig.json",    "start": "node dist/server.js"  },  "dependencies": { "express": "^4.21.2" },  "devDependencies": { "typescript": "^5.7.2" }}

Connecting GitHub to the server#

On the server's GitHub tab, install the app on your account or organisation, grant it the repositories you want reachable, then pick one repository and one branch.

The panel talks to GitHub through a GitHub App with short-lived installation tokens rather than asking you to paste a personal access token. That matters for two reasons. Private repositories work without a credential living in your server's config, and a token you have to remember to revoke is a token that never gets revoked. Removing the app's access to a repository in GitHub takes effect immediately, with nothing to clean up on the server side.

GitHub is the only provider here. If your code lives on GitLab, Gitea or a private server, mirror the branch to GitHub or fall back to uploading over SFTP - SFTP and the file manager covers doing that without making a mess of it.

The start command#

The start command runs on every start of the container, not only on deploy. For a compiled project with committed build output, this is the whole thing:

bash
npm ci --omit=dev && node dist/server.js

Use npm ci, not npm install. npm ci installs exactly what the lockfile says and fails loudly when the lockfile and package.json disagree, which is precisely what you want on a server. npm install quietly resolves something different from what you tested and then writes a changed lockfile that is not in your repository. The --omit=dev flag skips devDependencies, which usually halves both the install time and the disk used.

If the build has to happen on the server, the shape is:

bash
npm ci && npm run build && npm prune --omit=dev && node dist/server.js

That installs everything including the compiler, builds, removes the development packages again, and starts. It is correct, and it is slow: expect 30 to 90 seconds before the process listens, on every restart, and a memory peak during the build that is often two to three times what the running app needs. On a 1 GB plan a TypeScript or bundler build is the most likely thing to hit the ceiling. Two ways out, in order of preference: build in GitHub Actions and commit or release the output, or move up one tier for the memory during builds and back down if you find you do not need it. Node memory limits explained covers the heap flags and what the container limit really does.

The port, and the environment around it#

Your plan comes with an allocation - an address and a port shown on the Network tab - and that is the port your application must listen on. Two rules, both of which catch people:

Read the port from the environment, never hard-code it. Panels expose the allocated port to the process as an environment variable, and Node apps conventionally read PORT. Accept both and fall back to something sane for local development:

src/server.js
import express from "express";const app = express();const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 3000);app.set("trust proxy", 1);app.get("/healthz", (req, res) => res.status(200).send("ok"));const server = app.listen(port, "0.0.0.0", () => {  console.log(`listening on ${port}`);});process.on("SIGTERM", () => {  server.close(() => process.exit(0));});

Bind to `0.0.0.0`, not `127.0.0.1`. This is the number one cause of "it runs, the log says listening, and nothing can reach it". Inside a container, localhost means the container and nothing else. Express defaults to all interfaces if you omit the host, but many frameworks and examples default to loopback, and Vite's preview server and Next.js both have flags for it.

Environment variables live on the Startup tab. They are set on the container, so your code reads them the usual way and no secret is ever written into something you push. Database credentials, API keys, webhook URLs and bot tokens all belong there - environment variables and secrets covers the full list and what to do when one has already been committed.

Fail fast when one is missing, rather than starting and discovering it on the first request:

javascript
const required = ["DATABASE_URL", "SESSION_SECRET"];const missing = required.filter((name) => !process.env[name]);if (missing.length) {  console.error(`missing environment variables: ${missing.join(", ")}`);  process.exit(1);}

An app plan here includes database slots as well, created in the panel with a generated host, user and password, so the connection string is something you copy into the Startup tab rather than something you invent.

Auto update and deploy on push#

There are two independent switches, and they do different things.

Auto update pulls the branch every time the container starts. A restart for any reason - you pressed the button, the schedule fired, the process crashed - brings the latest commit with it. Useful, and worth knowing about, because it means a broken commit on your branch can be picked up by a restart you did not think of as a deploy.

Deploy on push reacts to GitHub reporting a push to that branch: it pulls and restarts the server. It only restarts a server that was already running, so a server you deliberately stopped stays stopped. That one detail saves a lot of confusion when you are working on something that is not meant to be live yet.

push to mainpush eventpull and restartclosed when runningYougit pushGitHub Appshort-lived tokenDeploy recordopened on pushYour containerpull, install, start
What happens between a push and a running process

The panel keeps one record per deploy, opened when the push arrives and closed once the container is seen running again. That is the difference between a deploy that shipped and a deploy that fell over, and it is the first place to look when the site is still serving yesterday's code: either no record exists, in which case the webhook or the branch name is wrong, or a record is open and never closed, in which case the container is not coming back up and the console has the reason.

A sensible default for a small project: deploy on push enabled on your production branch, auto update enabled as well so a crash-restart does not roll you backwards onto a stale checkout. If you are pushing to main many times a day, turn deploy on push off and use a release branch that you merge into deliberately.

A domain in front of the app#

App and web plans include a reverse-proxy slot. Point a hostname at the address shown on the slot with an A record, and the certificate is issued and renewed automatically - renewal happens inside a 21-day window, so there is nothing annual to remember.

Three things to do in your application once it is behind a proxy:

  1. Trust the proxy. The real client IP arrives in X-Forwarded-For. Until you tell your framework to trust that header, every request appears to come from the proxy, which breaks rate limiting, geolocation and your logs. In Express that is app.set("trust proxy", 1).
  2. Do not force HTTPS in the app. The proxy terminates TLS and speaks plain HTTP to your container. An app that redirects any non-HTTPS request to HTTPS will loop forever. Check X-Forwarded-Proto if you need to know.
  3. Generate absolute URLs from the public hostname, not from the request's host header, or OAuth callbacks and emailed links will point at the internal address.

Point a domain at your server and get an SSL certificate is the full walk-through including the DNS records and why issuance fails, and what a reverse proxy does is the background if the concept is new. If your app uses WebSockets, read websockets behind a reverse proxy before you debug a disconnect every sixty seconds.

When the first deploy fails#

In rough order of how often each one happens:

Nothing happens on push. Check the branch name matches exactly, including case. Check the GitHub App still has access to that repository. Check the server was running - deploy on push does not start a stopped server.

`npm ci` exits with `EUSAGE`. The lockfile and package.json disagree, or the lockfile is missing. Run npm install locally, commit the updated lockfile, push again.

The build is killed with no error. That is the memory limit. The container is stopped at the ceiling and restarted clean rather than being allowed to swap, so you get a truncated log rather than a stack trace. Build elsewhere or move up a tier.

The log says listening, nothing connects. Bound to 127.0.0.1, or bound to a port other than the allocated one. Both, occasionally.

`Error: Cannot find module` for something that is in package.json. Almost always --omit=dev removing a package your runtime actually needs - TypeScript path aliases and some ORMs pull in tooling at runtime. Move the package to dependencies.

A native module fails to load. bcrypt, sharp, better-sqlite3 and friends compile against the platform. Never commit node_modules; let npm ci build them on the server. If the build has no compiler available, switch to the pure-JS alternative (bcryptjs) or a prebuilt package.

The process restarts every few seconds. Read the console from the top, not the bottom - the useful error is the first one, and the loop buries it. Reading the console is about exactly this, and why your game server keeps restarting covers the crash-loop protection that eventually steps in.

`EADDRINUSE`. The previous process has not exited. That is a missing SIGTERM handler; see the server.close() block above and graceful shutdown and health checks.

FAQ#

Do I need Docker to deploy a Node app this way?

No. The server already runs your process in a container; you supply source and a start command, not an image. Custom images are not part of this flow. If you specifically want to control the runtime image, that is a VDS job - see choosing between VDS and a game panel.

Can I deploy from GitLab or a private Git server?

Not directly - the integration here is GitHub only, through a GitHub App. The usual workaround is a mirror: push to both remotes, or add a GitLab CI job that pushes to a GitHub repository which the server watches. Otherwise, upload the built application over SFTP.

How do I roll back a bad deploy?

Push a revert. git revert the commit and push, and deploy on push ships it the same way it shipped the mistake. If the app is down and you need it up now, pointing the server at the previous branch or tag and restarting is faster, but remember to move it back afterwards or the next push will appear to do nothing.

Does npm ci run on every restart?

Yes, if it is part of your start command, and that is usually what you want - it guarantees that what is installed matches the lockfile. The cost is 20 to 60 seconds of start time on a small plan. If your dependencies are stable and your start-up time matters more, you can drop npm ci from the start command and run it manually after a dependency change, but then a pull that changes dependencies will start with the wrong ones installed.

What happens to uploads and files my app writes?

They live on the server's disk and survive restarts, but anything inside the Git checkout can be overwritten by a pull. Write user uploads to a directory outside your repository path, or to a directory that is in .gitignore, and include it in your backups - backup slots come with the plan and can be run on a schedule.

How much memory does a Node app need?

A small API or a Discord bot is comfortable in 512 MB to 1 GB at rest; the build step is what needs headroom. Start at 1 GB, watch the console graph for a week, and move up if the ceiling is hit during builds rather than during traffic. Picking a plan for a Discord bot and sizing a web app for launch day go through the numbers.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000