# How to Deploy a Node.js App With Docker and a Database, Without Managing a Server [Full Repo]

A link shortener with a Dockerfile, pushed and live with a database in 42 seconds, no server to provision. Measured step by step, including the four things that went wrong.

URL: https://www.back4app.com/blog/deploy-node-app-dockerfile-back4app
Published: 2026-09-23 · Tested: 2026-09-10
Tested on: Containers: Back4app, free plan, 0.25 vCPU, 256 MB · Backend: Back4app, USA East · Local: Node 25, Express 5
Publisher: Back4app Engineering

Your app runs on `localhost`. It has a Dockerfile. And every hosting page you open answers the question "where does my code run?" — but not the one that actually keeps you up at night: "where does my data live, and who is patching that server?"

This post is about the ordinary case: a Node.js web app, Express serving a page and two routes, that needs somewhere to run and somewhere to keep its data. A **container platform** runs your code. A **backend** gives you the database, the rules and the auth you did not write. Most apps need both; few platforms ship both.

The same shape has three other posts, one per variant: a [Python API with FastAPI](https://www.back4app.com/blog/deploy-python-fastapi-dockerfile-database), a [server-rendered Next.js app](https://www.back4app.com/blog/deploy-nextjs-app-github-backend) and a [worker that must never sleep](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps).

We built a link shortener — 42 lines of Express, an 8-line Dockerfile — pushed it, and had it storing data behind HTTPS without a server to administer. The first deploy took **42 seconds** from click to `200`. Redeploys took 14 to 33 seconds. Everything below is from that run, on September 10, 2026, including the parts that broke.

**Get the working code:** the companion repo is at [github.com/templates-back4app/link-shortener](https://github.com/templates-back4app/link-shortener) — `server.js`, `Dockerfile`, `cloud/main.js`, and `deploy-check.sh`, which verifies a deployment end to end. Clone it, star it, or open an issue when something drifts.

## What does "without managing a server" include?

Say the phrase out loud and it sounds like one thing. It is five. A platform can honestly claim it only if it covers the whole list:

| What you need | Why it is not optional | Where it lives in this post |
|---|---|---|
| Your code running | The obvious part | Container, section 03 |
| HTTPS on a public URL | Browsers and app stores refuse plain HTTP | Container, automatic |
| A database you don't run | Data outlives the container | Backend, section 04 |
| Validation and auth outside the container | The container is disposable; the rules are not | Backend, section 05 |
| Redeploy on change, without downtime | Otherwise you are back to SSH | Section 06 |

Most container hosts stop at the first two: they run the Dockerfile and give you a URL. The database is a separate, separately billed service, and auth is yours to write.

Back4app is the one we tested because it ships the second half as a product: a **backend app** with a managed database, auth, REST and Cloud Code, that a **container app** talks to over the network. Two apps, one account, and the rest of this post is the measured walk through both.

## What does the link shortener look like, file by file?

A link shortener — the smallest app that exercises both halves. The container serves a page and two routes: `POST /shorten` creates a `Link` object in the backend, `GET /:code` looks it up and redirects. The backend owns the data and the rules.

![Architecture: the browser calls your container on b4a.run; the container reads and writes Link objects in the Back4app backend through the SDK; a Cloud Code beforeSave hook validates each Link before it reaches the managed database](/blog/blog-assets/deploy-node-app-dockerfile-back4app/architecture-diagram.svg)

Four files matter. The whole Dockerfile:

```dockerfile
# Stack: Docker | Node.js 22 (alpine) | File: Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "server.js"]
```

The container reads its configuration from the environment — nothing else is baked into the image:

```javascript
// Stack: Node.js 22.x | Express 5.x + Parse JS SDK 8.x | File: server.js (excerpt)
const { PORT = 8080, PARSE_APP_ID, PARSE_JS_KEY,
        PARSE_SERVER_URL = "https://parseapi.back4app.com" } = process.env;

Parse.initialize(PARSE_APP_ID, PARSE_JS_KEY);
Parse.serverURL = PARSE_SERVER_URL;

app.post("/shorten", async (req, res) => {
  const link = new Parse.Object("Link");
  link.set("url", String(req.body.url ?? ""));
  const saved = await link.save(); // the backend validates and assigns the code
  const short = `https://${req.get("host")}/${saved.get("code")}`;
  res.status(201).json({ code: saved.get("code"), short, url: saved.get("url") });
});
```

You need a free Back4app account, a GitHub account (the container deploys from a repository), and `curl`. No Docker on your machine: this whole post was produced on a Mac without Docker installed, because the platform builds the image.

## How do you deploy a Node.js Dockerfile from GitHub?

Two things have to exist first: the backend the container will talk to, and a GitHub repository the platform can read. We did the backend first so its keys were ready.

**Backend.** Dashboard → **New App → Build your Backend**, name it, **Create**. Eighty seconds later the Overview page shows the App ID and, under the Keys dropdown, the **JavaScript key** — the two values the container needs.

![The backend Overview page for link-shortener: App ID and the Keys dropdown on the left, Parse Server 7.5.2 and MongoDB 3.6 on the right](/blog/blog-assets/deploy-node-app-dockerfile-back4app/03-backend-keys-v2.jpg)

**Repository.** Push the four files to GitHub. Then, on the Containers side, **New App → Deploy from GitHub → Import GitHub Repo**. This opens GitHub's install page for the *Back4App Containers* app: pick **Only select repositories**, choose the repo, **Install & Authorize**. Sixteen seconds after that click the repo was listed in the dashboard.

![The Deploy a web app screen listing the link-shortener repository with a Select button, after the GitHub App was installed](/blog/blog-assets/deploy-node-app-dockerfile-back4app/02-repo-picker.jpg)

**Configure and deploy.** Select the repo and the form asks for a name, the branch, and a build method — it detected the Dockerfile. We left the port empty (the platform reads `EXPOSE`), set the health check to `/healthz`, and added two environment variables: `PARSE_APP_ID` and `PARSE_JS_KEY`. Then **Deploy**.

We clicked at 11:12:45. The log went `PREPARING → FETCHING GITHUB REPOSITORY → BUILDING IMAGE → Pushed image → LAUNCHING CONTAINER → CHECKING HEALTH → DEPLOYMENT READY`, and `/healthz` answered `200` at 11:13:27.

![The container Overview after the first deploy: status Available, build Ready in 29 s, the deployment log ending in DEPLOYMENT READY, and the configuration panel showing Dockerfile, port auto, 2 variables and health check /healthz](/blog/blog-assets/deploy-node-app-dockerfile-back4app/04-container-ready.jpg)

How we measured: a stopwatch from the Deploy click to the first `200` from the public URL, on the free plan (0.25 vCPU, 256 MB), one deploy on September 10, 2026. The redeploy times are four separate pushes the same day. Your build time moves with image size and registry speed; the numbers here are a reference, not a promise.

The URL is `https://linkshortener-<id>.b4a.run` — HTTPS included, no certificate to request. The free container gets 0.25 vCPU, 256 MB and 100 GB of transfer.

![The deployed link shortener page served from linkshortener-awjh19o8.b4a.run: a URL field and a Shorten button](/blog/blog-assets/deploy-node-app-dockerfile-back4app/06-live-app.jpg)

## How do you store data without a database?

You already did, in the code above: `link.save()` sends the object to the backend over HTTPS, using the App ID and key from the environment. There is no connection string, no pool, and no database process inside the container.

Here is the same write two ways. The container uses the SDK; the REST form is what the SDK sends, and it works from anywhere — including your terminal, with your own keys:

The first save from the container created the `Link` class on its own — no schema was defined beforehand (Parse Server 7.5.2 on Back4app, September 2026). Seconds later the objects were visible in the dashboard's Database Browser, with `url`, `code` and `clicks` columns inferred from what the container wrote.

![The Database Browser showing the Link class with three objects and their url, clicks and code columns](/blog/blog-assets/deploy-node-app-dockerfile-back4app/05-database-link.jpg)

Latency, measured from a laptop at UTC−3 with 10 samples each: a write that goes **through the container** to the backend took a median of 205 ms end to end; the same write sent **directly** to the backend took 440 ms. The container and the backend sit in the same region (USA East), so the hop between them is the cheap part of the trip.

## Where do validation and short-code generation live?

Everything that has to survive the container being replaced: the rules about your data, the keys, and the users. On our first run the backend accepted a `Link` whose `url` was the string `not a url`, and returned no short code — because nothing had told it what a valid Link is. That is the moment to add the hook.

`cloud/main.js` runs *inside the backend*, not in the container. It rejects bad URLs, sets `clicks` to 0, and allocates a code that no other Link uses:

```javascript
// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js
const ALPHABET = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const code = () => Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join("");

Parse.Cloud.beforeSave("Link", async (request) => {
  const link = request.object;
  if (!link.isNew()) return; // updates (click counts) pass through untouched

  let url;
  try { url = new URL(link.get("url")); } catch { url = null; }
  if (!url || !["http:", "https:"].includes(url.protocol)) {
    throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "url must be an http(s) URL.");
  }
  link.set("url", url.toString());
  link.set("clicks", 0);

  // assign a short code that no other Link uses
  for (let attempt = 0; attempt < 5; attempt++) {
    const candidate = code();
    const taken = await new Parse.Query("Link").equalTo("code", candidate).first({ useMasterKey: true });
    if (!taken) { link.set("code", candidate); return; }
  }
  throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, "could not allocate a short code.");
});
```

Paste it into **Cloud Code → cloud/main.js** in the backend dashboard and **Deploy**. Our hook was answering 21 seconds after the click. From then on the container's `POST /shorten` returned a `code`, and `not a url` came back as `400 {"code":142,"error":"url must be an http(s) URL."}` — the container's code did not change at all.

![The Cloud Code editor in the backend dashboard with cloud/main.js open, showing the beforeSave hook](/blog/blog-assets/deploy-node-app-dockerfile-back4app/07-cloud-code.jpg)

The same rule applies to keys and users. The container holds the *JavaScript* key, which is a client key — it can do exactly what a browser could. The master key never leaves the backend, and Parse's `User` class, sessions and ACLs are there when the shortener grows owners.

## What happens when you redeploy?

Auto deploy is off for free containers — the toggle is rendered disabled — so a `git push` alone changes nothing on the live URL. We pushed a one-line change at 11:26 and the site kept serving the old version until we deployed by hand.

**Deploy latest commit** does not deploy immediately. It first opens a *Finish your setup* review: the platform had read the repo as "Docker / Node.js / Express · Node.js 22 Alpine" and suggested `node server.js` and port 8080. Nothing is required; **Deploy without changes** starts the build.

![The Finish your setup dialog: the platform read the repo as Docker / Node.js / Express on Node.js 22 Alpine, suggests start command node server.js and port 8080, with Deploy without changes and Save and deploy buttons](/blog/blog-assets/deploy-node-app-dockerfile-back4app/08-finish-setup.jpg)

We measured four redeploys. A cached build with a one-line change went from click to the new version answering in **14 s**; the others took 20, 32 and 20 s. During every one of them a request every five seconds never saw anything but `200`: the deployments list shows the old container as *Destroying* only after the new one is *Ready*.

![The Deployments list after several redeploys: the newest deployment Ready in 20 s, the previous one Destroying, older ones Destroyed](/blog/blog-assets/deploy-node-app-dockerfile-back4app/09-deployments.jpg)

Environment variables get the same treatment: change one under **Settings → Environment**, and the dashboard says "Settings saved — deploy now with the settings you just saved?" A key rotation is a redeploy, not an SSH session.

![The Environment variables settings page listing PARSE_APP_ID and PARSE_JS_KEY, with the note that a change takes effect on the next deploy](/blog/blog-assets/deploy-node-app-dockerfile-back4app/10-env-settings.jpg)

## Which errors did the Node.js deploy throw, and what fixed them?

We broke the deployment on purpose three times. Two of the three did not behave the way error guides usually promise.

**`App is not listening on port 8080, but on port 3000 — switching to it (auto-detected)`.** We set `PORT=3000` so the app would ignore the `EXPOSE 8080` line. Expected: a failed health check. Actual: the platform probed, found 3000, logged the line above, saved 3000 to the app settings and reported `DEPLOYMENT READY` in 20 s. Removing the variable switched it back the same way. A port mismatch is not a failure here.

![The deployment log with the lines App is not listening on port 8080, but on port 3000 — switching to it (auto-detected) and Port 3000 saved to the app settings for future deploys, followed by DEPLOYMENT READY](/blog/blog-assets/deploy-node-app-dockerfile-back4app/11-port-autodetect.jpg)

**`{"error":"unauthorized"}` with HTTP 502 on `/shorten`, but the deploy said Ready.** We deployed with a wrong `PARSE_JS_KEY`. The health check hits `/healthz`, which never touches the backend, so the broken container passed and went live; the failure only surfaced on the first real request. Fix the key under Settings → Environment and deploy. Better: make your health check exercise the backend.

**`http://…` short links although the site is HTTPS.** The platform terminates TLS and — as of September 2026 — does not forward the scheme, so Express's `req.protocol` is `http` even with `trust proxy` on. Build public URLs with `https://` explicitly, as `server.js` now does.

**`Runtime Logs` show nothing after a failure.** Runtime Logs show your container's stdout and stderr, tagged with the container id — `console.log` lines as SYSTEM, `console.error` as ERROR. Our first version swallowed the `unauthorized` error and left no trace. Log your failures; then the logs page is the first place to look.

![Runtime Logs showing link-shortener listening on 8080 and an ERROR line shorten failed: 142 url must be an http(s) URL., each tagged with the container id](/blog/blog-assets/deploy-node-app-dockerfile-back4app/12-runtime-logs.jpg)

**`Website blocked — known phishing site` when you open your `b4a.run` URL.** Our fresh URL was blocked by NordVPN's Threat Protection in Chrome while `curl` reached it fine. The shared domain carries the reputation of every app on it. Verify with `curl -I` first, and put a custom domain on anything you show to users.

**`Nothing here, yet!` after installing the GitHub App.** The authorization callback landed in a browser that was not logged into the dashboard. Uninstall the app on GitHub (Settings → Applications), then redo *Import GitHub Repo* from the dashboard in the same browser.

**`Success on deploying your changes!` but Cloud Code does nothing.** Check Logs → System for `main.js not found`; make the edit in the editor itself so "Files pending deploy" appears, then deploy again.

## When is a container the wrong answer?

Per our own manifesto, comparisons where only the platform wins don't get published — so here is the other column.

- **Your app is CRUD and nothing else.** Then you don't need a container at all: the backend's REST layer already is the API. Our [REST API post](https://www.back4app.com/blog/build-rest-api-nodejs-back4app) builds one in 24 lines and zero containers.
- **You need a specific database engine or extension.** The backend is Parse Server on MongoDB (Postgres on paid plans). If your app is PostGIS or ClickHouse, the data half of this post does not apply.
- **You need the URL to outlive an hour, auto deploy, or more than 0.25 vCPU.** All three are paid features here as of September 15, 2026 (Shared plans start at $5/month); check the pricing page rather than this post for current numbers.
- **You want to build images locally and push them.** The image route accepts Docker Hub only — our image on GitHub Container Registry was rejected at the form.

If none of those describe you, the shape in the diagram — a disposable container in front of a managed backend — is the one we would choose again.

## Where do you take the link shortener from here?

You have a container that redeploys in under half a minute, a database you never provisioned, and validation that survives the container being replaced. From here:

- **Give links owners.** Parse's `User` class adds signup and login endpoints with no container code ([measured, with password reset](https://www.back4app.com/blog/add-signup-login-password-reset-without-an-auth-server)); a `beforeSave` can stamp the creating user on each Link and an ACL only they can edit ([how the locks behave](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to)).
- **Put a domain on it.** Settings → Domains on the container — the fix for the reputation problem above.
- **Make the health check honest.** Have `/healthz` run a cheap query against the backend so a bad key fails the deploy instead of the first user.

All code above is complete and tested — the [companion repo](https://github.com/templates-back4app/link-shortener) includes `deploy-check.sh`, which runs health → shorten → redirect against any URL and exits non-zero if anything is off.
