Install
Markdown
Ask AI
Share

Deploy an Express App to a VPS With Docker and Health Checks

Deploy a Node.js Express API to your own server with no Dockerfile. Covers the port rule, a health route, secrets, deploy failure codes and rollback.

Markdown
Ask AI
Share

By the end of this guide an Express API runs in Docker on your own server, built from git without a Dockerfile, gated by a health route, with secrets stored encrypted and one rollback rehearsed. The tool is Levelrail, a self-hosted deploy platform. You need a server with it installed (see the five minute install) and an Express repo on GitHub, GitLab, Bitbucket or Gitea.

An Express API is easy to deploy and easy to deploy badly. The usual mistakes are small: binding to the wrong address, skipping a health route, leaving secrets in a file. Levelrail builds Node.js projects with Railpack, which reads your package.json and works out the rest.

Checked on 2026-11-01 against the current Levelrail release.

Deploy an Express app: the short version#

StepWhat you do
1Listen on the injected PORT, on all interfaces
2Add a /healthz route and a start script
3Write a short app.yaml
4Create the app from git
5Read logs, then practice a rollback

Listen on the right port and address#

The platform injects a PORT environment variable. Your server must bind to it, and to 0.0.0.0. A server that listens only on localhost starts fine, then fails every readiness check, because nothing outside the container can reach it.

js
// server.js
import express from "express";

const app = express();
const port = Number(process.env.PORT) || 3000;

app.get("/healthz", (_req, res) => res.json({ ok: true }));
app.get("/", (_req, res) => res.send("hello"));

app.listen(port, "0.0.0.0", () => console.log(`listening on ${port}`));

The platform reports this mistake with the failure code port_not_listening. Its suggested fix matches: bind 0.0.0.0 on the spec port.

Keep /healthz cheap. It should return without touching your database. A readiness check that depends on a flaky dependency marks your whole app unready for reasons your release did not cause.

Railpack runs npm run start, so package.json needs a start script:

json
{
  "scripts": { "start": "node server.js" },
  "type": "module"
}

Check what the platform detects#

Before you build, ask what Levelrail sees in your repo:

bash
levelrail-cli apps analyze YOUR_REPO_URL

It prints the framework, build type, port and runtime version with the reasons behind each, and creates nothing. The “Deploy from git” wizard shows the same plan. Detection only reads files like package.json and never runs your code. For Express it should report Node.js. Fix a wrong detection now, because it costs less than a failed build.

Write the app spec#

yaml
version: 1
services:
  web:
    build:
      type: railpack
    port: 3000
    health:
      readiness: { path: /healthz, interval: 5s, timeout: 2s }
    env:
      NODE_ENV: production
      JWT_SECRET: { secret: true, required: true }

A secret marked required: true has no value in the file. The deploy fails with RequiredSecretMissing until you supply one, and the serving release stays untouched.

Create the Express app from git#

bash
REPO_URL=your-git-repo-url
IMAGE_REPO=your-registry-path

levelrail-cli apps create \
  --name api \
  --file app.yaml \
  --repo "$REPO_URL" \
  --ref main \
  --image-repo "$IMAGE_REPO" \
  --build-type railpack \
  --secret JWT_SECRET=your-secret-value

levelrail-cli apps deploys wait api

The build starts the moment the app exists. deploys wait blocks until the deploy settles: exit 0 means healthy, 7 means failed, canceled, superseded or blocked, and 6 means timeout.

If you rebuild by hand later with apps builds trigger, pass --build-type railpack again. The trigger does not remember it, and without the flag it looks for a Dockerfile. To deploy on every push, connect your git provider in the dashboard and use push trigger mode, as described in git integrations.

Checkpoint: levelrail-cli apps status api shows the app running and ready.

Change settings after the first deploy#

Plain settings go in the env: block of app.yaml. Secrets never go in the file. Set or change one from the CLI, then apply it so the running container restarts with the new value:

bash
levelrail-cli apps secrets set api JWT_SECRET your-new-value
levelrail-cli apps apply api

apps status api prints a pending changes line until you apply. The value reaches the container only when Levelrail creates it, which is why the apply step exists. Never log secrets from your own code, and keep them out of the repo and out of build output.

Read logs and failure codes#

bash
levelrail-cli apps logs api -f
levelrail-cli apps deploys show api

A failed deploy carries one structured failure: a code, the cause, the failing step, the last log lines and a suggested fix.

CodeUsual cause
port_not_listeningBound to localhost or the wrong port
health_check_failedThe readiness path is slow or returns an error
missing_envA required secret has no value
build_config_errorNo start script in package.json
lockfile_out_of_syncThe lockfile is missing or stale. Commit it

For a slow cold start, raise health.readyTimeout (default 60s) instead of loosening the check.

Roll back an Express deploy#

bash
levelrail-cli apps deploys list api
levelrail-cli apps deploys rollback-to api <deploy-id>

Levelrail pins previous images by digest, so a rollback redeploys exactly what ran before. The full picture is in zero downtime deploys for Docker apps. Your app should also exit cleanly on SIGTERM: see graceful shutdown for Node apps in Docker.

What can go wrong#

SymptomCause and fix
Deploy fails readinessThe app listens on localhost. Bind 0.0.0.0 on PORT.
“Dockerfile not found” on a manual rebuildPass --build-type railpack to apps builds trigger.
Front end values are emptyNEXT_PUBLIC_ and VITE_ values compile in at build time. Platform env never reaches the build.
Language not detectedRailpack builds are live-verified for Node.js, Go, Java with Spring Boot and Python. Other languages need a Dockerfile.

What I have not verified yet#

I have not deployed this exact sample for this post, so it has no pasted terminal output. The commands and failure codes come from the current CLI reference and deploy failure docs. A real run log would replace this section.

FAQ#

Do I need a Dockerfile for Express?#

No. Railpack builds Node.js from package.json. Add a Dockerfile only when you need control of the image.

What should my health route check?#

One thing: can this process serve a request right now. Leave the database out.

How do I add a database?#

Create one and connect it with a single command. See Postgres next to your app.

Where next?#

Reference: Deploying and managing apps, the app.yaml reference and Deploy failures.

Levelrail is open source under Apache 2.0. If this saved you time, a star on the repo helps other self-hosters find it.

Next steps

Keep reading

Zero downtime, with a way back Zero downtime, with a way back

explainer 5 min read

Zero Downtime Deploy for Docker Apps, With Rollback

How a Docker deploy waits for readiness before cutover, holds the previous release for rollback, rolls back on its own, and how to rehearse it.

  • Docker
  • Deployments
  • Rollback