Eine Express-App auf DigitalOcean deployen
Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig
Du hast eine Express-App, die auf deinem Laptop mit node server.js einwandfrei läuft, und einen DigitalOcean-Account. Was fehlt, ist alles zwischen diesen beiden Tatsachen: ein Server, der auch nach dem Schließen deines Terminals läuft, eine echte Postgres-Datenbank statt eines In-Memory-Objekts, ein Container, der den Prozess bei einem Absturz neu startet, und ein Zertifikat, damit die App unter https:// statt einer blanken IP-Adresse antwortet. Für sich allein ist nichts davon schwer, aber jedes Teil hat seine eigene kleine Art, schiefzugehen.
Diese Anleitung baut das Ganze mit Docker Compose: ein Container für Postgres, einer für die Express-App und einer für Caddy, das HTTPS übernimmt. Die App selbst ist bewusst klein: zwei Routen, von denen eine durch einen echten pg-Pool gestützt wird, sodass es eine tatsächliche Datenbankverbindung zu konfigurieren gibt und nicht nur eine statische Antwort. Am Ende hast du ein Droplet, das die App auf deiner eigenen Domain ausliefert, mit einer wiederholbaren Methode, neue Versionen auszuliefern und die Datenbank zu sichern. Danach schauen wir uns die Abkürzung an: dasselbe Repository zu Railyard pushen und es für dich die package.json lesen lassen.
Was du brauchst
- Ein DigitalOcean-Droplet. Der Basic-Plan mit 1 vCPU / 2 GB RAM betreibt Postgres, den Node-Prozess und Caddy zusammen bequem und kostet derzeit 12 $/Monat; der 1-GB-Plan (6 $/Monat) funktioniert für eine kleine App ohne viel gleichzeitige Last, lässt aber wenig Spielraum, sobald Postgres auch läuft.
- Ein Domainname mit Zugriff, um einen
A-Record hinzuzufügen. - Ein GitHub-(oder ein beliebiges Git-Host-)Repository mit deinem Express-Projekt.
- Ein DigitalOcean-Account mit eingerichteter Zahlung und einem zu deinem Account hinzugefügten SSH-Key.
- Lokal installiertes Docker, falls du die Compose-Datei und die Caddyfile vor dem Kopieren auf den Server validieren willst, was diese Anleitung tut.
Schritt 1: das Droplet erstellen
Erstelle im DigitalOcean-Dashboard ein neues Droplet: wähle das Ubuntu-24.04-LTS-Image, den 2-GB-Basic-Plan, eine Region nahe an deinen Nutzern und deinen SSH-Key. Sobald es läuft, notiere seine öffentliche IP-Adresse und richte den A-Record deiner Domain darauf aus:
myapp.example.com. A 203.0.113.20
Die DNS-Propagation kann einige Minuten bis einige Stunden dauern. Du kannst in der Zwischenzeit mit dem Server-Setup weitermachen; Caddy braucht den DNS-Eintrag nur dann korrekt, wenn es versucht, ein Zertifikat auszustellen.
Verbinde dich per SSH als root und installiere Docker:
ssh root@203.0.113.20
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Prüfe, ob es funktioniert hat:
docker compose version
Du solltest eine Versionsangabe für das Compose-Plugin sehen, keinen „command not found”-Fehler.
Schritt 2: die Express-App schreiben
Express 5.3.0 ist die aktuelle npm-Version von Express 5, dem aktuellen Major-Release, und pg 8.23.1 ist die aktuelle npm-Version von node-postgres, dem Standard-Treiber, um ohne ORM mit Postgres von Node aus zu sprechen. Initialisiere das Projekt und installiere beide:
npm init -y
npm install express@^5.3.0 pg@^8.23.1
package.json sollte am Ende diese Felder enthalten, plus das, was npm init ausgefüllt hat:
{
"name": "myapp",
"version": "1.0.0",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.3.0",
"pg": "^8.23.1"
}
}
Jetzt server.js. Diese App hat zwei Routen: einen einfachen Health-Check und eine, die Postgres über einen Connection-Pool abfragt, der DATABASE_URL aus der Umgebung liest.
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}`);
});
Ein paar Dinge sind erwähnenswert. app.listen bindet an 0.0.0.0, nicht an das Standard-localhost, was wichtig wird, sobald die App in einem Container läuft: Ein Prozess, der nur auf localhost innerhalb eines Containers lauscht, ist von außerhalb unerreichbar, selbst wenn der Port veröffentlicht ist. Die App liest process.env.PORT und fällt für die lokale Entwicklung auf 3000 zurück, aber das Droplet-Setup unten setzt PORT explizit, sodass der Fallback dort nie tatsächlich zum Einsatz kommt.
Die /widgets-Route hat kein try/catch um den await pool.query(...)-Aufruf, und das ist Absicht: Express 5 hat geändert, wie es Fehler behandelt, die innerhalb von async-Routen-Handlern geworfen werden. In Express 4 musste ein abgelehntes Promise innerhalb eines async-Handlers manuell abgefangen und an next(err) übergeben werden, sonst hing der Request ohne jede Antwort. Express 5 macht das automatisch: Ein abgelehntes Promise aus einem async-Handler wird genauso an die Error-Handling-Middleware weitergeleitet wie ein synchrones throw. Deshalb reicht der Error-Handler am Ende dieser Datei (der mit vier Argumenten) aus, um eine fehlgeschlagene Query zu fangen, ohne jeden Routen-Body in try/catch zu wickeln.
Du brauchst außerdem eine Tabelle, damit diese Query überhaupt etwas zurückgibt. Sobald Postgres läuft (Schritt 4 unten), erstelle sie:
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());"
Schritt 3: das Dockerfile schreiben
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 ist ein aktuelles Node.js-LTS-Release und ein kleines Basis-Image. Die deps-Stage führt npm ci --omit=dev allein gegen die Lockfile aus, sodass die Installation der Dependencies separat vom Kopieren des restlichen Quellcodes gecacht wird; eine Änderung an server.js zwingt npm ci nicht zur erneuten Ausführung. Die runner-Stage kopiert die installierten node_modules aus deps, dann den App-Quellcode, und läuft als node, dem nicht-root-Nutzer, der im offiziellen Node-Image bereits vorhanden ist, statt als root. EXPOSE 3000 dokumentiert den Port, auf dem die App innerhalb des Containers lauscht; es veröffentlicht von sich aus nichts; das übernehmen Caddy und Compose im nächsten Schritt.
Schritt 4: docker-compose.yml schreiben
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:
Nur Caddy veröffentlicht Ports nach außen; web und db bleiben nur von Containern im selben Compose-Netzwerk aus erreichbar, was ein Ding weniger ist, das versehentlich dem Internet ausgesetzt ist. web hängt davon ab, dass db gesund ist, nicht nur gestartet, sodass die App nicht versucht, sich zu verbinden, bevor Postgres tatsächlich Verbindungen akzeptiert.
Ich habe diese Datei lokal validiert, bevor ich sie verwendet habe:
docker compose -f /tmp/compose-check/docker-compose.yml config
Sie wurde ohne Warnungen sauber geparst, sobald POSTGRES_PASSWORD in der Umgebung gesetzt war (Compose warnt, löst aber trotzdem auf, wenn eine interpolierte Variable wie ${POSTGRES_PASSWORD} zum Zeitpunkt von config nicht gesetzt ist; auf dem Droplet kommt sie aus .env, eingerichtet in Schritt 6).
Schritt 5: die Caddyfile schreiben
myapp.example.com {
reverse_proxy web:3000
}
Caddy fordert ein Let’s-Encrypt-Zertifikat für myapp.example.com an und erneuert es automatisch beim ersten Start, solange der DNS-Eintrag der Domain bereits auf diesen Server zeigt und die Ports 80 und 443 ihn erreichen. Alles wird zum Express-Prozess auf Port 3000 weitergeleitet, demselben Port, den das Dockerfile freigibt und auf dem die App lauscht.
Ich habe diese Caddyfile mit Caddys eigenem Validator geprüft, bevor ich sie auf das Droplet kopiert habe:
docker run --rm -v /tmp/compose-check/Caddyfile:/etc/caddy/Caddyfile caddy:2 caddy validate --config /etc/caddy/Caddyfile
Sie gab Valid configuration ohne Fehler aus.
Schritt 6: Umgebungsvariablen und Secrets setzen
Erstelle eine .env-Datei neben docker-compose.yml, halte sie aus der Versionskontrolle heraus und schränke ihre Berechtigungen ein:
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
Erzeuge das Passwort mit openssl rand -base64 32 statt etwas Merkbares zu tippen, und setze die Dateiberechtigungen so, dass nur root sie lesen kann:
chmod 600 .env
Beachte, dass DATABASE_URL auf db zeigt, den Servicenamen aus docker-compose.yml, nicht auf localhost. Container im selben Compose-Netzwerk lösen sich gegenseitig über den Servicenamen auf; localhost innerhalb des web-Containers bezieht sich auf den web-Container selbst, auf dem kein Postgres läuft.
Schritt 7: Firewall öffnen und deployen
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
Dann, aus dem Verzeichnis mit deiner docker-compose.yml:
docker compose up -d --build
docker compose logs -f caddy
Du solltest sehen, dass Caddy loggt, es habe ein Zertifikat für deine Domain erhalten, dann sollte docker compose logs -f web listening on 3000 ohne Verbindungsfehler zeigen. Besuche https://myapp.example.com in einem Browser: Du solltest {"status":"ok"} von der Root-Route sehen, und https://myapp.example.com/widgets sollte ein leeres Array (oder die Zeilen, die du in Schritt 2 eingefügt hast) statt eines 500ers zurückgeben.
Schritt 8: Backups und Upgrades
Sichere Postgres mit einem geplanten Dump, der außerhalb des Containers geschrieben wird, damit ein verlorenes Volume nicht auch das Backup mitnimmt:
docker compose exec -T db pg_dump -U myapp myapp > backup-$(date +%F).sql
Lege das in einen Cronjob und kopiere das Ergebnis irgendwohin außerhalb des Droplets (DigitalOcean Spaces, S3, oder einfach scp auf eine andere Maschine). Um ein neues Release auszuliefern, ziehe deinen neuesten Code, baue neu und bring den web-Container wieder hoch; Postgres und Caddy müssen nicht neu starten:
git pull
docker compose up -d --build web
Die Abkürzung: mit Railyard
Alles oben (das Dockerfile, der Non-Root-Nutzer, DATABASE_URL, der Caddy-Proxy und das Zertifikat) ist das, was Railyard automatisch einrichtet, wenn du es auf dasselbe Repository zeigst. Laut eigenen Erkennungsregeln: Der Paketmanager wird anhand der im Repo vorhandenen Lockfile ausgewählt (package-lock.json, pnpm-lock.yaml, yarn.lock oder bun.lockb), und das start-Skript in package.json läuft als Web-Prozess, sobald der Build abgeschlossen ist; falls das Repo stattdessen ein eigenes Dockerfile mitbringt, wird dieses direkt gebaut und ausgeführt. Der Container lauscht auf dem von Railyard zugewiesenen $PORT, derselben Umgebungsvariable, die die server.js dieser Anleitung bereits liest. Ein npm run worker-Skript, falls es in package.json existiert, wird als Hintergrund-Worker-Prozess neben dem Web-Prozess angeboten. Eine pg-Abhängigkeit in package.json reicht Railyard, um automatisch eine verwaltete Postgres-Datenbank bereitzustellen, mit DATABASE_URL als eingefügte Umgebungsvariable, kein Addon, das man von Hand hinzufügen müsste, und kein Dockerfile nötig für ein Repo, das keines mitbringt. Siehe die Node.js-Docs für die genauen Erkennungsregeln.
Um so zu deployen: erstelle ein Droplet oder einen Server über Railyard (DigitalOcean ist einer der unterstützten Provider), füge dann die Git-URL deines Repositories ein, oder fügen das für einen Klick zu deiner README hinzu:
[](https://app.railyard.run/deploy?repo=https://github.com/you/your-express-app)
Railyards Free-Plan deckt einen Server plus einen Datenbankserver und drei Apps ab, keine Karte erforderlich. Pro kostet 19 $/Monat pro Team, und Team kostet 49 $/Monat pro Team für höhere Limits und mehr Mitarbeiter.
Den manuellen Weg oben zu kennen, lohnt sich trotzdem. Es ist das, was Railyard im Hintergrund macht, und ihn zu verstehen bedeutet, dass du nie feststeckst, wenn etwas eine Änderung braucht, die das Dashboard noch nicht anbietet.
Troubleshooting
„Port is already allocated” beim Ausführen von docker compose up. Etwas anderes ist schon an Port 80 oder 443 gebunden, meist ein Standard-nginx, das von einem DigitalOcean-One-Click-Image installiert wurde. Stoppe und deaktiviere es: systemctl stop nginx && systemctl disable nginx, dann noch einmal versuchen.
Postgres-Verbindung abgelehnt, oder ECONNREFUSED in den App-Logs. Prüfe DATABASE_URL in .env: Sie muss auf db zeigen, den Compose-Servicenamen, nicht auf localhost. localhost innerhalb des web-Containers bedeutet den web-Container, in dem kein Postgres läuft.
Permission denied beim Schreiben einer Datei, oder der Container beendet sich sofort mit einem EACCES-Fehler. Der Container läuft als der Non-Root-Nutzer node aus Schritt 3. Wenn deine App in ein Verzeichnis schreibt, das ihr nicht gehört (eine Logdatei, ein ins Image eingebackener Upload-Ordner), erstelle dieses Verzeichnis entweder mit den richtigen Besitzrechten im Dockerfile, bevor du zu USER node wechselst, oder schreibe stattdessen in ein gemountetes Volume.
HTTPS kommt nie, oder Caddy loggt Zertifikatsfehler. Prüfe, dass der A-Record der Domain tatsächlich auf das Droplet auflöst (dig myapp.example.com) und dass die Ports 80 und 443 sowohl in der Droplet-Firewall als auch in DigitalOceans Cloud-Firewall offen sind, falls du eine hinzugefügt hast. Let’s Encrypt validiert zuerst über reines HTTP, also muss Port 80 erreichbar sein, auch wenn die finale Seite nur HTTPS anbietet.
Die App wird gekillt, oder das Droplet fühlt sich unter echtem Traffic langsam an. Auf einem 1-GB-Droplet konkurrieren Postgres, der Node-Prozess und Caddy alle um denselben Speicher, und ein Node-Prozess unter gleichzeitiger Last kann seinen Heap schnell wachsen lassen. Beobachte docker stats unter Last, füge als Sicherheitsnetz eine Swap-Datei hinzu, oder steig auf das 2-GB-Droplet um, wenn der Traffic echt ist.
FAQ
Brauche ich Docker, oder kann ich das mit einem Prozessmanager und einer System-Node-Installation betreiben? Docker Compose hält Postgres, die App und Caddy isoliert und einfach zwischen Servern verschiebbar, aber pm2 oder systemd, die node server.js gegen ein systemweit installiertes Postgres betreiben, funktionieren genauso; der app.listen-Aufruf und die Caddy-Konfiguration oben ändern sich in keinem Fall.
Was hat sich zwischen Express 4 und 5 tatsächlich geändert, das hier relevant ist? Die für diese Anleitung relevante Änderung ist die automatische Weiterleitung abgelehnter Promises aus async-Routen-Handlern an die Error-Handling-Middleware, weshalb /widgets oben kein try/catch um sein await hat. Express 5 hat auch einige lange als veraltet markierte APIs entfernt und die unterstützte Mindest-Node.js-Version angehoben, aber diese Anleitung verlässt sich auf nichts weiteres, das sich geändert hat.
Kann ich ein ORM wie Prisma oder Sequelize statt rohem pg verwenden? Ja. Beide lesen DATABASE_URL genauso wie pg.Pool hier, und das Dockerfile, die Compose-Datei und die Caddyfile ändern sich nicht; nur server.js und die Abhängigkeit in package.json ändern sich. Diese Anleitung verwendet rohes pg, um das Beispiel klein zu halten und einen Build-Schritt für eine Schema-Datei zu vermeiden.
Wie füge ich einen Hintergrund-Worker hinzu? Füge einen worker-Service zu docker-compose.yml hinzu, der aus demselben Image und derselben .env-Datei baut, mit einem command, der etwas anderes als server.js ausführt, zum Beispiel node worker.js. Er kann denselben pg.Pool und DATABASE_URL wie der Web-Prozess nutzen, oder Redis als weiteren Container hinzufügen, falls die Job-Queue eines braucht.
Reicht das 1-GB-Droplet wirklich aus? Für eine kleine App ohne viel gleichzeitigen Traffic, ja, die 6-$/Monat-Stufe funktioniert gut. Sobald Postgres, der Node-Prozess und Caddy zusammen unter echter Last laufen, gibt der 2-GB-Plan für 12 $/Monat genug Spielraum, damit ein Traffic-Peak nicht zu einem Out-of-Memory-Kill wird.
Eine Express-App auf einem Droplet ist nicht zerbrechlich, sobald die Teile richtig zusammengesteckt sind, und das meiste hier ist nicht spezifisch für Express: derselbe Postgres-Container, derselbe Caddy-Proxy und derselbe Backup-Cronjob funktionieren auch für die nächste Node-App, die du auf diesen Server stellst.
Warum Leute sich dafür Railyard entscheiden
- Dein eigenes Cloud-Konto. Deploye auf DigitalOcean, Hetzner, Vultr, AWS, OVHcloud oder jedem Linux-Server per SSH, direkt von deinem Anbieter abgerechnet, ohne Aufschlag.
- Ein fester Preis pro Team. Free ist für immer kostenlos: ein Server plus ein Datenbankserver, drei Apps, keine Karte. Pro kostet 19 $ im Monat für bis zu fünf Server und unbegrenzte Apps. Team kostet 49 $ im Monat mit unbegrenzten Servern. Keine Kosten pro App, pro Deployment, pro Sitz oder pro Gigabyte.
- Templates mit einem Klick. Deploye Open-Source-Apps, wie Datenbanken, Analytics und Dev-Tools, mit einem Klick auf deinem eigenen Server.
- Verwaltete Datenbanken. Postgres, MySQL und Redis mit geplanten, wiederherstellungsgeprüften Backups und Point-in-Time-Recovery.
- Push-to-Deploy. Deploye von GitHub, GitLab oder der CLI, mit automatischem HTTPS und Releases ohne Ausfallzeit.