Deploy an Express app to DigitalOcean
Follow this on your own server. Start free, no card needed
You have an Express app that runs fine with node server.js on your laptop, and a DigitalOcean account. What’s missing is everything between those two facts: a server that stays up after you close your terminal, a real Postgres database instead of an in-memory object, a container that restarts the process if it crashes, and a certificate so the app answers on https:// instead of a bare IP address. None of that is hard on its own, but each piece has its own small way to go wrong.
This guide builds the whole thing with Docker Compose: one container for Postgres, one for the Express app, and one for Caddy handling HTTPS. The app itself is small on purpose: two routes, one of them backed by a real pg Pool, so there’s an actual database connection to configure and not just a static response. By the end you’ll have a droplet serving the app on your own domain, with a repeatable way to ship new versions and back up the database. Then we’ll look at the shortcut: pushing the same repository to Railyard and letting it read package.json for you.
What you’ll need
- A DigitalOcean droplet. The Basic plan at 1 vCPU / 2 GB RAM runs Postgres, the Node process and Caddy together comfortably and currently costs $12/month; the 1 GB plan ($6/month) works for a small app without much concurrent load, but leaves little headroom once Postgres is also running.
- A domain name, with access to add an
Arecord. - A GitHub (or any Git host) repository containing your Express project.
- A DigitalOcean account with billing set up, and an SSH key added to your account.
- Docker installed locally if you want to validate the Compose file and Caddyfile before copying them to the server, which this guide does.
Step 1: create the droplet
From the DigitalOcean dashboard, create a new droplet: choose the Ubuntu 24.04 LTS image, the 2 GB Basic plan, a region close to your users, and your SSH key. Once it’s running, note its public IP address and point your domain’s A record at it:
myapp.example.com. A 203.0.113.20
DNS propagation can take a few minutes to a few hours. You can continue setting up the server in the meantime; Caddy only needs the DNS record to be correct by the time it tries to issue a certificate.
SSH in as root and install Docker:
ssh root@203.0.113.20
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Check it worked:
docker compose version
You should see a version string for the Compose plugin, not a “command not found” error.
Step 2: write the Express app
Express 5.3.0 is the current npm version of Express 5, the current major release, and pg 8.23.1 is the current npm version of node-postgres, the standard driver for talking to Postgres from Node without an ORM. Initialize the project and install both:
npm init -y
npm install express@^5.3.0 pg@^8.23.1
package.json should end up with these fields, plus whatever npm init filled in:
{
"name": "myapp",
"version": "1.0.0",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.3.0",
"pg": "^8.23.1"
}
}
Now server.js. This app has two routes: a plain health check, and one that queries Postgres through a connection pool reading DATABASE_URL from the environment.
const express = require("express");
const { Pool } = require("pg");
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get("/", (req, res) => {
res.json({ status: "ok" });
});
app.get("/widgets", async (req, res) => {
const result = await pool.query(
"SELECT id, name, created_at FROM widgets ORDER BY id DESC LIMIT 20"
);
res.json(result.rows);
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "internal error" });
});
const port = process.env.PORT || 3000;
app.listen(port, "0.0.0.0", () => {
console.log(`listening on ${port}`);
});
A few things worth noticing. app.listen binds to 0.0.0.0, not the default localhost, which matters once the app runs inside a container: a process listening only on localhost inside a container is unreachable from outside it, even with the port published. The app reads process.env.PORT, falling back to 3000 for local development, but the droplet setup below sets PORT explicitly so the fallback never actually gets used there.
The /widgets route has no try/catch around the await pool.query(...) call, and that’s deliberate: Express 5 changed how it handles errors thrown inside async route handlers. In Express 4, a rejected promise inside an async handler had to be caught and passed to next(err) manually, or it would hang the request with no response at all. Express 5 does this automatically: a rejected promise from an async handler is forwarded to the error-handling middleware the same way a synchronous throw would be. That’s why the error handler at the bottom of this file (the one with four arguments) is enough to catch a failed query without wrapping every route body in try/catch.
You’ll also need a table for that query to return anything. Once Postgres is running (Step 4 below), create it:
docker compose exec -T db psql -U myapp -d myapp -c "CREATE TABLE widgets (id serial PRIMARY KEY, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());"
Step 3: write the Dockerfile
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
node:24-alpine is a current Node.js LTS release and a small base image. The deps stage runs npm ci --omit=dev against the lockfile alone, so dependency installation is cached separately from copying the rest of the source; a change to server.js doesn’t force npm ci to rerun. The runner stage copies the installed node_modules from deps, then the app source, and runs as node, the non-root user already present in the official Node image, rather than root. EXPOSE 3000 documents the port the app listens on inside the container; it doesn’t publish anything by itself; that’s handled by Caddy and Compose in the next step.
Step 4: write docker-compose.yml
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 5
web:
build: .
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
expose:
- "3000"
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
volumes:
postgres_data:
caddy_data:
caddy_config:
Only Caddy publishes ports to the outside world; web and db stay reachable only from containers on the same Compose network, which is one less thing exposed to the internet by accident. web depends on db being healthy, not just started, so the app doesn’t try to connect before Postgres is actually accepting connections.
I validated this file locally before using it:
docker compose -f /tmp/compose-check/docker-compose.yml config
It parsed cleanly with no warnings once POSTGRES_PASSWORD was set in the environment (Compose warns, but still resolves, when an interpolated variable like ${POSTGRES_PASSWORD} is unset at config time; on the droplet it comes from .env, set up in Step 6).
Step 5: write the Caddyfile
myapp.example.com {
reverse_proxy web:3000
}
Caddy requests and renews a Let’s Encrypt certificate for myapp.example.com automatically the first time it starts, as long as the domain’s DNS already points at this server and ports 80 and 443 reach it. Everything is proxied to the Express process on port 3000, the same port the Dockerfile exposes and the app listens on.
I validated this Caddyfile with Caddy’s own validator before copying it to the droplet:
docker run --rm -v /tmp/compose-check/Caddyfile:/etc/caddy/Caddyfile caddy:2 caddy validate --config /etc/caddy/Caddyfile
It printed Valid configuration with no errors.
Step 6: set your environment variables and secrets
Create a .env file next to docker-compose.yml, keep it out of version control, and lock down its permissions:
POSTGRES_DB=myapp
POSTGRES_USER=myapp
POSTGRES_PASSWORD=a-long-random-password
DATABASE_URL=postgres://myapp:a-long-random-password@db:5432/myapp
PORT=3000
Generate the password with openssl rand -base64 32 rather than typing something memorable, and set the file’s permissions so only root can read it:
chmod 600 .env
Notice that DATABASE_URL points at db, the service name from docker-compose.yml, not localhost. Containers on the same Compose network resolve each other by service name; localhost inside the web container refers to the web container itself, which isn’t running Postgres.
Step 7: open the firewall and deploy
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
Then, from the directory with your docker-compose.yml:
docker compose up -d --build
docker compose logs -f caddy
You should see Caddy log that it obtained a certificate for your domain, then docker compose logs -f web should show listening on 3000 with no connection errors. Visit https://myapp.example.com in a browser: you should see {"status":"ok"} from the root route, and https://myapp.example.com/widgets should return an empty array (or the rows you inserted in Step 2) rather than a 500.
Step 8: backups and upgrades
Back up Postgres with a scheduled dump, written outside the container so a lost volume doesn’t take the backup with it:
docker compose exec -T db pg_dump -U myapp myapp > backup-$(date +%F).sql
Put that in a cron job and copy the result somewhere off the droplet (DigitalOcean Spaces, S3, or just scp to another machine). To ship a new release, pull your latest code, rebuild, and bring the web container back up; Postgres and Caddy don’t need to restart:
git pull
docker compose up -d --build web
The shortcut: do it with Railyard
Everything above (the Dockerfile, the non-root user, DATABASE_URL, the Caddy proxy and certificate) is what Railyard sets up automatically when you point it at the same repository. From its own detection rules: the package manager is picked from whichever lockfile is present in the repo (package-lock.json, pnpm-lock.yaml, yarn.lock, or bun.lockb), and the start script in package.json runs as the web process once the build completes; if the repo ships its own Dockerfile instead, that’s built and run directly. The container listens on the $PORT Railyard assigns, the same environment variable this guide’s server.js already reads. An npm run worker script, if one exists in package.json, is offered as a background worker process alongside the web one. A pg dependency in package.json is enough for Railyard to provision a managed Postgres database automatically, with DATABASE_URL injected as an environment variable, no addon to add by hand and no Dockerfile required for a repo that doesn’t ship one. See the Node.js docs for the exact detection rules.
To deploy this way: create a droplet or server through Railyard (DigitalOcean is one of the supported providers), then paste your repository’s Git URL, or add this to your README for one click:
[](https://app.railyard.run/deploy?repo=https://github.com/you/your-express-app)
Railyard’s Free plan covers one server plus a database server and three apps, no card required. Pro is $19/month per team, and Team is $49/month per team for higher limits and more collaborators.
The manual path above is still the right one to know. It’s what Railyard is doing under the hood, and understanding it means you’re never stuck if something needs a change the dashboard doesn’t expose yet.
Troubleshooting
“Port is already allocated” when running docker compose up. Something else is already bound to port 80 or 443, usually a default nginx installed by a DigitalOcean one-click image. Stop and disable it: systemctl stop nginx && systemctl disable nginx, then try again.
Postgres connection refused, or ECONNREFUSED in the app logs. Check DATABASE_URL in .env: it needs to point at db, the Compose service name, not localhost. localhost inside the web container means the web container, which has no Postgres running in it.
Permission denied writing a file, or the container exits immediately with an EACCES error. The container runs as the non-root node user from Step 3. If your app writes to a directory it doesn’t own (a log file, an upload folder baked into the image), either create that directory with the right ownership in the Dockerfile before switching to USER node, or write to a mounted volume instead.
HTTPS never comes up, or Caddy logs certificate errors. Check that the domain’s A record actually resolves to the droplet (dig myapp.example.com) and that ports 80 and 443 are open in both the droplet’s firewall and DigitalOcean’s cloud firewall if you added one. Let’s Encrypt validates over plain HTTP first, so port 80 has to be reachable even though the final site is HTTPS-only.
The app gets killed or the droplet feels slow under real traffic. On a 1 GB droplet, Postgres, the Node process and Caddy are all competing for the same memory, and a Node process under concurrent load can grow its heap quickly. Watch docker stats under load, add a swap file as a safety net, or move up to the 2 GB droplet if traffic is real.
FAQ
Do I need Docker, or can I run this with a process manager and a system Node install? Docker Compose keeps Postgres, the app and Caddy isolated and easy to move between servers, but pm2 or systemd running node server.js against a system-installed Postgres work the same way; the app.listen call and the Caddy configuration above don’t change either way.
What actually changed between Express 4 and 5 that matters here? The relevant change for this guide is automatic forwarding of rejected promises from async route handlers to error-handling middleware, which is why /widgets above has no try/catch around its await. Express 5 also dropped some long-deprecated APIs and bumped minimum Node.js version support, but this guide doesn’t rely on anything else that changed.
Can I use an ORM like Prisma or Sequelize instead of raw pg? Yes. Either one reads DATABASE_URL the same way pg.Pool does here, and the Dockerfile, Compose file and Caddyfile don’t change; only server.js and the dependency in package.json do. This guide uses raw pg to keep the example small and avoid a build step for a schema file.
How do I add a background worker? Add a worker service to docker-compose.yml, building from the same image and .env file, with a command that runs something other than server.js, for example node worker.js. It can use the same pg.Pool and DATABASE_URL as the web process, or add Redis as another container if the job queue needs one.
Is the 1 GB droplet really enough? For a small app without much concurrent traffic, yes, the $6/month tier works fine. Once Postgres, the Node process and Caddy are all running together under real load, the 2 GB plan at $12/month gives enough headroom that a traffic spike doesn’t turn into an out-of-memory kill.
An Express app on a droplet isn’t fragile once the pieces are wired up correctly, and most of what’s here isn’t specific to Express: the same Postgres container, the same Caddy proxy and the same backup cron job work for the next Node app you put on this server too.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.