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#
| 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.
// 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:
{
"scripts": { "start": "node server.js" },
"type": "module"
}Check what the platform detects#
Before you build, ask what Levelrail sees in your repo:
levelrail-cli apps analyze YOUR_REPO_URLIt 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#
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#
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 apiThe 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:
levelrail-cli apps secrets set api JWT_SECRET your-new-value
levelrail-cli apps apply apiapps 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#
levelrail-cli apps logs api -f
levelrail-cli apps deploys show apiA 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#
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#
| 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.
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.