# 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.

Canonical URL: https://levelrail.com/blog/deploy-express-app-docker-vps/

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](https://github.com/glincker/levelrail), a self-hosted deploy platform. You need a server with it installed (see [the five minute install](https://levelrail.com/blog/install-levelrail-five-minutes/)) 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

| Step | What you do |
| --- | --- |
| 1 | Listen on the injected `PORT`, on all interfaces |
| 2 | Add a `/healthz` route and a `start` script |
| 3 | Write a short `app.yaml` |
| 4 | Create the app from git |
| 5 | Read 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](https://docs.levelrail.com/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.

| Code | Usual cause |
| --- | --- |
| `port_not_listening` | Bound to `localhost` or the wrong port |
| `health_check_failed` | The readiness path is slow or returns an error |
| `missing_env` | A required secret has no value |
| `build_config_error` | No `start` script in `package.json` |
| `lockfile_out_of_sync` | The 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](https://levelrail.com/blog/zero-downtime-deploy-docker-rollback/). Your app should also exit cleanly on SIGTERM: see [graceful shutdown for Node apps in Docker](https://levelrail.com/blog/node-sigterm-graceful-shutdown-docker/).

## What can go wrong

| Symptom | Cause and fix |
| --- | --- |
| Deploy fails readiness | The app listens on `localhost`. Bind `0.0.0.0` on `PORT`. |
| "Dockerfile not found" on a manual rebuild | Pass `--build-type railpack` to `apps builds trigger`. |
| Front end values are empty | `NEXT_PUBLIC_` and `VITE_` values compile in at build time. Platform env never reaches the build. |
| Language not detected | Railpack 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](https://levelrail.com/blog/self-hosted-postgres-docker-connect-app/).

### Where next?

Reference: [Deploying and managing apps](https://docs.levelrail.com/deploying-apps), [the app.yaml reference](https://docs.levelrail.com/app-spec-reference) and [Deploy failures](https://docs.levelrail.com/deploy-failures).

Levelrail is open source under Apache 2.0. If this saved you time, a star on [the repo](https://github.com/glincker/levelrail) helps other self-hosters find it.
