Production Production
Deploying a ZudoJS app
Take the Task API from your computer to a server. Build it for production, run it under systemd or in Docker, add PostgreSQL and Redis with Docker Compose, put Caddy in front for HTTPS, run migrations during a deployment, read the logs and back up the database.
From your computer to a server
Deploying means putting your app on a computer that is always on and reachable from the internet, a server, and keeping it running there. The path in this lesson:
- Build the app for production and run it without any development tools.
- Run it on a Linux server as a service that restarts when it crashes.
- Package it as a Docker image, so it runs the same everywhere.
- Start it together with PostgreSQL, Redis and a reverse proxy using Docker Compose.
- Serve it over HTTPS on your own domain.
- Deploy new versions safely: migrations, logs, backups.
Every command below was run for real on the Task API from Create the Task API project. Your ids, times and sizes will differ.
Prepare the app
In production the app needs a database. Add the PostgreSQL driver, pg:
npm install pg … npm install -D @types/pg …
Then add a few small files and changes. First, a function that creates a connection pool with the limits you chose in Production engineering. It refuses to start without a database address:
import pg from "pg";
/** A connection pool with production limits. Refuses to start without a URL. */
export function createPool(connectionString: string | undefined): pg.Pool {
if (!connectionString) throw new Error("DATABASE_URL is not set");
return new pg.Pool({
connectionString,
max: 10,
connectionTimeoutMillis: 2_000,
statement_timeout: 5_000,
});
}
Second, make the database an integration. You saw the empty src/integrations/ folder in the tour of the project: an integration starts with the app, stops after everything else, and reports its health to /health. The generated src/configs/index.ts already reads DATABASE_URL into config.database.url:
import type pg from "pg";
import { createPool } from "../databases/pool.js";
import type { Integration } from "./integration.js";
let pool: pg.Pool | undefined;
/** The shared pool, for repositories. Only valid while the app runs. */
export function db(): pg.Pool {
if (pool === undefined) throw new Error("The postgres integration has not started");
return pool;
}
export const postgres: Integration = {
name: "postgres",
async start({ config, logger }) {
pool = createPool(config.database.url);
pool.on("error", (error) => logger.warn("idle database connection lost", { error: error.message }));
await pool.query("SELECT 1");
},
async health() {
await db().query("SELECT 1");
return true;
},
async stop() {
await pool?.end();
pool = undefined;
},
};
startcreates the pool and runsSELECT 1, so the app refuses to start when the database is unreachable, instead of failing on the first customer's request.pool.on("error", …)matters more than it looks. When the database restarts,pgreports the broken idle connections as anerrorevent. Without a listener, Node.js treats that as an uncaught error and the whole app stops. With it, the app logs a warning and opens new connections when the database is back.healthis what/healthreports as"postgres": "up"or"down".
Register it in src/integrations/index.ts, between the markers the CLI left there:
// zudojs:integration-imports:start
import { postgres } from "./postgres.js";
// zudojs:integration-imports:end
export const integrations: readonly Integration[] = [
// zudojs:integrations:start
postgres,
// zudojs:integrations:end
];
Third, trust forwarded headers only from the proxy you will add later. Add a trustProxy setting to src/configs/index.ts, and pass it to the adapter in src/server.ts. An empty value means "trust no proxy":
// zudojs:config:start
database: Object.freeze({ url: text(config, "database_url", "") }),
trustProxy: text(config, "trust_proxy", ""),
// zudojs:config:end
adapter: createNodeHttpAdapter({
server: httpServer,
host: config.host,
port: config.port,
trustProxy: config.trustProxy || false,
}),
You do not need to touch the shutdown code: on SIGTERM the generated src/server.ts stops the HTTP server, then the runtime, which stops the integrations last and so closes the pool.
Last, a migration script. A migration is a numbered change to the database structure. The script applies the ones that have not run yet and records them in a schema_migrations table. The advisory lock makes two copies of the script, started at the same moment by two servers, wait for each other instead of both creating the table:
import { createPool } from "./databases/pool.js";
const migrations = [
{ id: 1, sql: "CREATE TABLE tasks (id SERIAL PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT false)" },
];
const pool = createPool(process.env["DATABASE_URL"]);
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT pg_advisory_xact_lock(4242)");
await client.query("CREATE TABLE IF NOT EXISTS schema_migrations (id INT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())");
for (const migration of migrations) {
const done = await client.query("SELECT 1 FROM schema_migrations WHERE id = $1", [migration.id]);
if (done.rowCount) continue;
await client.query(migration.sql);
await client.query("INSERT INTO schema_migrations (id) VALUES ($1)", [migration.id]);
console.log(`applied migration ${migration.id}`);
}
await client.query("COMMIT");
console.log("database is up to date");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
await pool.end();
}
NOTE
If you use Prisma through@zudojs/database, its createMigrationRunner does the same job, with the same kind of lock. The idea is identical: migrations are code, versioned in Git, applied by a command, never by hand.The production build
In production you run compiled JavaScript with plain node, not tsx. npm run build runs tsc, which writes JavaScript into dist/:
npm run build > task-api@0.1.0 build > tsc ls dist/*.js dist/databases dist/integrations dist/app.js dist/container.js dist/index.js dist/migrate.js dist/server.js dist/databases: index.js pool.js dist/integrations: index.js integration.js postgres.js
On the server you install only the packages the app needs to run. npm ci installs exactly the versions in package-lock.json, and --omit=dev leaves out TypeScript, tsx and Vitest. Then you start the compiled server with the production settings:
npm ci --omit=dev NODE_ENV=production DATABASE_URL=postgres://… node dist/server.js
That is the whole app: a dist/ folder, node_modules, and environment variables.
On a Linux server
Most servers run Linux, rented from a provider as a VPS (virtual private server). The basic setup, once:
- Log in with an SSH key, not a password, and turn password logins off.
- Install security updates, and turn on automatic ones.
- Allow only ports 22 (SSH), 80 and 443 (web) in the firewall. PostgreSQL's 5432 is never open to the internet.
- Install Node.js 24 from the official NodeSource or distribution packages, and PostgreSQL.
- Create a user that runs only the app, with no login and no admin rights:
sudo useradd --system --home /srv/task-api taskapi.
Keep it running with systemd
If you start node dist/server.js in an SSH session, it stops when you log out, and nothing restarts it after a crash or a reboot. A process manager does that. On Linux, the built-in one is systemd. Describe the app in a unit file, /etc/systemd/system/task-api.service:
# /etc/systemd/system/task-api.service [Unit] Description=Task API (ZudoJS) After=network-online.target postgresql.service Wants=network-online.target [Service] Type=simple User=taskapi WorkingDirectory=/srv/task-api Environment=NODE_ENV=production EnvironmentFile=/etc/task-api/env ExecStart=/usr/bin/node dist/server.js Restart=on-failure RestartSec=5 KillSignal=SIGTERM TimeoutStopSec=40 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true [Install] WantedBy=multi-user.target
User=taskapi: the app never runs as root.EnvironmentFile: the secrets,DATABASE_URLand friends, oneNAME=valueper line. Make the file readable only by root and the app:sudo chmod 640 /etc/task-api/envandsudo chown root:taskapi /etc/task-api/env.Restart=on-failure: if the process crashes, systemd starts it again after 5 seconds.KillSignal=SIGTERMandTimeoutStopSec=40: on stop, the app getsSIGTERMand 40 seconds for its graceful shutdown, more than the runtime's own 30.- The last four lines lock the process down: it cannot gain privileges, write to the system, or read home folders.
Check the file before you install it. No output means it is valid:
systemd-analyze verify task-api.serviceThen, on the server, load it, start it, and look at it:
sudo systemctl daemon-reload sudo systemctl enable --now task-api systemctl status task-api journalctl -u task-api -f
enable --now starts the app and also starts it at every boot. journalctl -u task-api -f follows its logs, everything the app writes to standard output, live.
TIP
pm2 is a popular process manager written in Node.js (npm install -g pm2, then pm2 start dist/server.js --name task-api). It works on any system and has a nice dashboard. On a Linux server systemd is already there and does the same job, so this course uses it.Docker
A server set up by hand is hard to reproduce: which Node.js version, which system packages, which settings? Docker packages your app together with everything it needs to run into an image. A running copy of an image is a container. The same image runs on your laptop, in CI and on the server.
The recipe for an image is a Dockerfile. This one uses a multi-stage build: the first stage has all the tools to compile TypeScript, and the second, final stage gets only the compiled JavaScript and the production packages. The image is smaller and contains no compiler or test tools an attacker could use.
# Dockerfile # Stage 1: install every dependency and compile TypeScript FROM node:24-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY tsconfig.json ./ COPY src ./src RUN npm run build # Stage 2: only what production needs FROM node:24-alpine ENV NODE_ENV=production WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=build /app/dist ./dist USER node EXPOSE 3000 HEALTHCHECK --interval=10s --timeout=3s CMD wget -qO- http://127.0.0.1:3000/health || exit 1 CMD ["node", "dist/server.js"]
node:24-alpineis the official Node.js 24 image on Alpine Linux, a very small Linux.- The
package.jsonfiles are copied before the source code. Docker caches each step, so when only your code changes,npm ciis not run again and the build takes seconds. USER node: the official image has a user without admin rights. Use it.HEALTHCHECKasks/healthevery 10 seconds, sodocker psshows whether the app is healthy.CMDuses the list form, sonodeitself receivesSIGTERMand can shut down gracefully.
TIP
zudojs add docker writes a similar multi-stage Dockerfile, a .dockerignore and a compose.yaml for you. This lesson writes its own so you understand every line. It also uses npm ci with package-lock.json, so the image gets exactly the versions you tested; the 2.1.0 CLI's file runs npm install without the lock file.A .dockerignore file keeps local files out of the image. Above all, .env with your secrets must never be copied in:
# .dockerignore node_modules dist .env .git tests
Build the image and give it a name and a version, a tag:
docker build -t task-api:1.0.0 . #1 [internal] load build definition from Dockerfile #1 transferring dockerfile: 623B 0.0s done #1 DONE 0.1s … #9 [stage-1 4/5] RUN npm ci --omit=dev && npm cache clean --force #9 48.08 #9 48.08 added 31 packages, and audited 32 packages in 46s … #9 48.09 found 0 vulnerabilities … #9 DONE 49.6s #8 [build 4/7] RUN npm ci #8 60.51 #8 60.51 added 81 packages, and audited 82 packages in 59s … #8 DONE 60.9s … #12 [build 7/7] RUN npm run build #12 0.737 #12 0.737 > task-api@0.1.0 build #12 0.737 > tsc #12 0.737 #12 DONE 1.5s #13 [stage-1 5/5] COPY --from=build /app/dist ./dist #13 DONE 0.2s #14 exporting to image … #14 naming to docker.io/library/task-api:1.0.0 0.0s done #14 DONE 1.5s docker images task-api IMAGE ID DISK USAGE CONTENT SIZE EXTRA task-api:1.0.0 59ebb5d3dd0c 184MB 0B
The production stage installed 31 packages, the build stage 81: the other 50 are development tools that never reach the final image. Now run it:
docker run --rm task-api:1.0.0 2026-09-23T17:45:14.777Z [ERROR] [task-api] Module "integrations" failed during initialization. error={"name":"Error","message":"DATABASE_URL is not set",…} 2026-09-23T17:45:14.785Z [ERROR] [task-api] Runtime failed to start. errorMessage="Module \"integrations\" failed during initialization." 2026-09-23T17:45:14.786Z [INFO] [task-api] Rolling back module startup. 2026-09-23T17:45:14.787Z [INFO] [task-api] Module rollback complete. failedModules=[] … RuntimeInitializationError: Module "integrations" failed during initialization. at runStartup (file:///app/node_modules/@zudojs/runtime/dist/startup/startup.core.js:68:15) { cause: Error: DATABASE_URL is not set at createPool (file:///app/dist/databases/pool.js:5:15) … Node.js v24.21.0
That failure is your createPool check doing its job: the postgres integration could not start, so the runtime undid the start-up and the app stopped, with the reason in the cause. The container needs a database next to it, and that is what Docker Compose is for.
Docker Compose: app, PostgreSQL, Redis and a proxy
Docker Compose starts several containers that belong together from one file, compose.yaml, and puts them on a private network where each one can reach the others by its service name. That is the DNS-based service discovery from the microservices lesson: the app connects to db:5432.
# compose.yaml
services:
app:
build: .
image: task-api:1.0.0
environment:
NODE_ENV: production
PORT: "3000"
DATABASE_URL: postgres://taskapi:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/taskapi
REDIS_URL: redis://redis:6379
TRUST_PROXY: private
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17
environment:
POSTGRES_USER: taskapi
POSTGRES_DB: taskapi
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskapi -d taskapi"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
redis:
image: redis:8-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
restart: unless-stopped
proxy:
image: caddy:2-alpine
ports:
- "8080:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
depends_on:
- app
restart: unless-stopped
volumes:
pgdata:
redisdata:
- Only the proxy has
ports. The app, the database and Redis are reachable only inside the Compose network, never from the internet. - Volumes (
pgdata,redisdata) keep the data when containers are replaced. Without them, every deployment would delete your database. depends_onwithservice_healthystarts the app only after PostgreSQL and Redis pass their health checks.${POSTGRES_PASSWORD:?…}takes the password from the environment or from a.envfile next tocompose.yaml, and stops with a message if it is missing. There is no default password.REDIS_URLis ready for a shared cache or rate-limit store, as recommended in the previous lesson.TRUST_PROXY: privatetells the app to believeX-Forwarded-Foronly from addresses on private networks, which is where Caddy sits.
The proxy's configuration, Caddyfile, forwards every request to the app:
# Caddyfile
:80 {
reverse_proxy app:3000
}
Try to start it without a password, then create one. openssl rand -hex 24 prints 48 random characters, so nobody ever types or sees the password:
docker compose up -d error while interpolating services.app.environment.DATABASE_URL: required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in .env error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in .env echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" > .env chmod 600 .env
Now start the database and Redis first, run the migrations, then start everything:
docker compose up -d db redis … Container task-api-db-1 Started Container task-api-redis-1 Started docker compose run --rm app node dist/migrate.js Container task-api-redis-1 Healthy Container task-api-db-1 Healthy … applied migration 1 database is up to date docker compose up -d … Container task-api-app-1 Started Container task-api-proxy-1 Starting Container task-api-proxy-1 Started docker compose ps --format 'table {{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' NAME IMAGE STATUS PORTS task-api-app-1 task-api:1.0.0 Up 8 seconds (health: starting) 3000/tcp task-api-db-1 postgres:17 Up About a minute (healthy) 5432/tcp task-api-proxy-1 caddy:2-alpine Up 8 seconds 443/tcp, 2019/tcp, 443/udp, 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp task-api-redis-1 redis:8-alpine Up About a minute (healthy) 6379/tcp curl -i http://localhost:8080/health HTTP/1.1 200 OK Content-Length: 81 Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' Content-Type: application/json … Via: 1.1 Caddy X-Content-Type-Options: nosniff … {"status":"ok","checks":{"postgres":"up"},"timestamp":"2026-09-23T17:47:11.474Z"} docker compose exec redis redis-cli ping PONG
The request went to Caddy on port 8080 (the Via: 1.1 Caddy header), which passed it to the app. The app asked the postgres integration, which answered up. The security headers from the app came through the proxy unchanged. Running the migrations a second time is safe: the script prints only database is up to date.
See the readiness check work
Stop the database and ask again:
docker compose stop db Container task-api-db-1 Stopping Container task-api-db-1 Stopped curl -i http://localhost:8080/health HTTP/1.1 503 Service Unavailable … {"status":"unavailable","checks":{"postgres":"down"},"timestamp":"2026-09-23T17:48:48.033Z"} docker compose start db Container task-api-db-1 Starting Container task-api-db-1 Started curl http://localhost:8080/health {"status":"ok","checks":{"postgres":"up"},"timestamp":"2026-09-23T17:48:55.064Z"}
Exactly as planned in the previous lesson: the generated /health is a readiness check. Without its database the app answers 503 and says which part is down, but it keeps running, logs one warning (you will see it in the logs below), and recovers by itself when the database is back. Docker marks the container unhealthy after three failed checks but does not restart it; Kubernetes would stop sending it traffic.
NOTE
When this lesson's example was first run without thepool.on("error", …) line, stopping the database crashed the app: the proxy answered 502 Bad Gateway, and restart: unless-stopped restarted the app over and over until the database was back. One missing listener turned a database restart into an outage.Reverse proxy, domain and HTTPS
A reverse proxy is a server that sits in front of your app and receives every request first. It handles HTTPS (the encryption of every request between the browser and your server), compresses responses, and can spread traffic over several copies of the app. Your Node.js process then only speaks plain HTTP on the private network.
Point your domain at the server
At the company where you bought your domain, create a DNS A record (and an AAAA record for IPv6) for the name you want, for example api.example.com, pointing to your server's IP address. Changes can take a few minutes to an hour to reach everyone. Check with dig +short api.example.com: it should print your server's address.
HTTPS with Caddy
For a real domain, replace :80 in the Caddyfile with the name, and publish ports 80 and 443 instead of 8080:
# Caddyfile on the server
api.example.com {
reverse_proxy app:3000
encode zstd gzip
}
That is all. When Caddy starts, it gets a free TLS certificate from Let's Encrypt for api.example.com, renews it before it expires, redirects HTTP to HTTPS, and adds the Strict-Transport-Security header. It needs ports 80 and 443 open and the DNS record in place. Add a volume for /data in the proxy service so the certificates survive a restart.
The same with nginx
nginx is the other common choice. It does not get certificates by itself; you use certbot for that. The part that forwards to the app looks like this:
# /etc/nginx/sites-available/task-api
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
TRUST ONLY YOUR OWN PROXY
Behind a proxy, every request reaches the app from the proxy's address, and the real client address is in theX-Forwarded-For header. Anyone can send that header, so the app must believe it only when it comes from your proxy. That is what trustProxy on the adapter does: "loopback" when nginx runs on the same machine, "private" inside Docker. Never set it to "all" on an app that is also reachable directly, or an attacker can pick their own IP address and walk around your per-IP rate limit.Logs
The app writes its logs to standard output, and the platform keeps them. With Compose:
docker compose logs app --tail 3 app-1 | 2026-09-23T17:48:30.225Z [INFO] [task-api] Runtime is ready. runtimeId=rt_c17db7ea3c694c5d812f61ae9be12a4b environment=production app-1 | Listening on http://0.0.0.0:3000 app-1 | 2026-09-23T17:48:45.394Z [WARN] [task-api] idle database connection lost error="terminating connection due to administrator command" docker compose stop app Container task-api-app-1 Stopping Container task-api-app-1 Stopped docker compose logs app | grep -i "shutdown" app-1 | 2026-09-23T17:49:04.437Z [INFO] [task-api] Initiating graceful shutdown. timeoutMs=30000 app-1 | 2026-09-23T17:49:04.445Z [INFO] [task-api] Graceful shutdown complete.
docker compose stop sent SIGTERM, and the logs show the graceful shutdown from the previous lesson taking 8 milliseconds. Add -f to follow logs live. Under systemd the same is journalctl -u task-api. Docker keeps logs in files that grow forever by default; limit them with the logging option (max-size: "10m", max-file: "3") or ship them to a log service, as the production lesson recommends.
Deploying a new version
Every deployment follows the same steps, in this order:
- Back up the database (next section).
- Build the new image with a new tag, such as
task-api:1.1.0. Keep the old image so you can go back. - Migrate the database with the new image, before the new app starts.
- Replace the app container. The old one gets
SIGTERMand finishes its requests. - Check
/health, the logs and your dashboard for a few minutes.
docker compose build app … Image task-api:1.0.0 Built docker compose run --rm app node dist/migrate.js database is up to date docker compose up -d app … Container task-api-app-1 Started curl http://localhost:8080/health {"status":"ok","checks":{"postgres":"up"},"timestamp":"2026-09-23T17:49:26.911Z"}
Migrations that do not break the running app
Between step 3 and step 4, the old app runs against the new database. So every migration must work with both versions. Split breaking changes into two deployments, called expand and contract:
- Renaming a column
titletoname: deployment 1 addsname, copies the data, and the new code writes both columns. Deployment 2, once no old code is left, removestitle. - Adding a
NOT NULLcolumn: add it with a default, or as nullable first and fill it, then add the constraint later. - Never edit a migration that already ran in production. Add a new one.
To roll back a bad release, start the previous image tag again. That only works if the database still suits it, which is one more reason for expand and contract.
Backups
A backup you have never restored is only a hope. Make one with pg_dump in PostgreSQL's compact custom format, then prove it works by restoring it into a scratch database:
docker compose exec db psql -U taskapi -d taskapi -c "INSERT INTO tasks (title) VALUES ('Write the deploy guide'), ('Test the backup')" INSERT 0 2 mkdir -p backups docker compose exec -T db pg_dump -U taskapi -d taskapi --format=custom > backups/taskapi-2026-09-23.dump ls -lh backups -rw-rw-r-- 1 you you 3.7K Sep 23 18:49 taskapi-2026-09-23.dump docker compose exec -T db createdb -U taskapi restore_check docker compose exec -T db pg_restore -U taskapi -d restore_check < backups/taskapi-2026-09-23.dump docker compose exec db psql -U taskapi -d restore_check -c "SELECT id, title, done FROM tasks" id | title | done ----+------------------------+------ 1 | Write the deploy guide | f 2 | Test the backup | f (2 rows) docker compose exec -T db dropdb -U taskapi restore_check
Both rows came back from the file. The rules for real backups:
- Automate them: a nightly
cronjob or systemd timer that runs thepg_dumpline with the date in the file name. - Store them somewhere else: a backup on the same server dies with the server. Copy it to object storage in another region, encrypted.
- Keep several: for example 7 daily, 4 weekly and 12 monthly copies.
- Test a restore regularly, exactly as above.
Managed databases from cloud providers do nightly backups and point-in-time recovery for you, which is a good reason to use one once real money depends on your data.
Clean up
When you are done experimenting on your computer, remove the containers, the network, the volumes (this deletes the database) and your image:
docker compose down --volumes Container task-api-proxy-1 Stopping Container task-api-proxy-1 Stopped … Container task-api-redis-1 Removed Container task-api-db-1 Removed Volume task-api_redisdata Removing Network task-api_default Removing Volume task-api_pgdata Removing Volume task-api_redisdata Removed Volume task-api_pgdata Removed Network task-api_default Removed docker image rm task-api:1.0.0 Untagged: task-api:1.0.0 Deleted: sha256:90feffb7effdc7af96a374c1a6bee299066727e5d0a179e83e4532ffb0b2f828
On a real server, never run down --volumes: it deletes your production data.
Practice
TRY IT YOURSELF
Add log limits
Docker's default log files grow without limit and can fill the server's disk. Add a logging section to the app service in compose.yaml that keeps at most 3 files of 10 MB.
Show a solution
app:
# … everything from before
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Run docker compose up -d app to apply it. The app keeps at most 30 MB of logs; older lines are dropped. Ship logs to a log service if you need to keep them longer.
TRY IT YOURSELF
A nightly backup
Write the line for a crontab that makes a backup every night at 02:30, with the date in the file name, and deletes backups older than 14 days. Remember that % has a special meaning in crontab.
Show a solution
30 2 * * * cd /srv/task-api && docker compose exec -T db pg_dump -U taskapi -d taskapi --format=custom > backups/taskapi-$(date +\%F).dump && find backups -name '*.dump' -mtime +14 -delete
date +%F prints the date as 2026-09-23; in a crontab each % must be written \%. Add a second job that copies the newest file to storage outside the server, and a reminder to test a restore every month.
TRY IT YOURSELF
Spot the mistakes
Find four production problems in this Compose service:
app:
image: task-api:latest
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://admin:admin123@db:5432/taskapi
TRUST_PROXY: allShow a solution
task-api:latest: you cannot tell which version runs or go back to the previous one. Use version tags.ports: "3000:3000"publishes the app directly to the internet, around the proxy and its HTTPS.- The password is written in the file, it is weak, and
adminsuggests a database superuser. Use${POSTGRES_PASSWORD:?…}from.env, a random value, and a user that owns only this database. TRUST_PROXY: alltogether with a published port lets any client fake its IP address withX-Forwarded-For. Trust only the proxy's network.
Recap
- Production runs compiled JavaScript:
npm run build,npm ci --omit=dev,node dist/server.js, with every setting in environment variables. - On a Linux server, systemd keeps the app running, restarts it after a crash, and holds its secrets in an
EnvironmentFile. - A multi-stage Dockerfile builds a small image with no build tools, running as a non-root user.
- Docker Compose runs the app with PostgreSQL, Redis and Caddy on a private network. Only the proxy is published, data lives in volumes, and a missing password stops the start.
- Caddy gets and renews HTTPS certificates for your domain by itself. Trust forwarded headers only from your own proxy.
- Every deployment: back up, build a new tag, migrate, replace, check. Write migrations that suit both versions, and test your backups by restoring them.
You can now ship a ZudoJS app. In the capstone you design and build a bigger one from scratch: ShopFlow.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.