Deployment
Four processes, one nginx, and the configuration that fails without saying so.
There is no deploy button. ShipKit is four long-running Bun processes and a Postgres, and it runs anywhere that can hold those: a €5 VPS, a Hetzner box, a container platform. This page describes a plain Linux server with systemd and nginx, because that is the deployment we run and can therefore describe honestly.
What has to run
| Process | What it is | Needs a port |
|---|---|---|
apps/web | Next.js, the product UI | yes |
apps/api | Elysia, the REST API | yes |
apps/admin | Next.js, the admin panel | yes |
apps/worker | BullMQ consumer | no |
apps/docs and apps/mcp are optional. The worker has no port because nothing
connects to it; it drains queues and exits on SIGTERM.
Build order matters
Build with NODE_ENV=production set at build time, not only at run time.
Bun inlines process.env.NODE_ENV into the bundle, so a build that ran without
it produces artefacts that believe they are in development no matter what the
service file says at boot. We have watched this surface as sessions recording
127.0.0.1 as the client address for every user on a deployment built without
it. Export it before the first build command and the whole class goes away.
export NODE_ENV=production
# The API exports its OpenAPI spec in a prebuild step, which imports the app
# and therefore validates the environment. On a build host without the full
# production .env, skip that validation:
SKIP_ENV_VALIDATION=true bun run --filter=@workspace/api build
bun run --filter=web build
bun run --filter=admin build
bun run --filter=@workspace/worker buildThe API and the worker both build to dist/index.js with --target bun, and
are started with bun dist/index.js. The Next apps build in place and are
started with next start.
Postgres
Two extensions must exist before the schema is pushed:
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector, for the embeddings table
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- trigram search helpersdrizzle-kit push does not create extensions, and it does not tell you that is
the problem. It fails with:
error: type "vector" does not existLocally this never comes up, because docker-compose.yml uses the
pgvector/pgvector:pg16 image and mounts scripts/init-db.sql, which creates
both on the container's first boot. The moment you point at a Postgres you
created yourself, that step is yours. Extensions need a superuser, so this runs
as postgres, not as the application role.
Then push the schema:
bun run db:pushSee Database for push against migrate, and for what to do once the database holds data you care about.
systemd
One unit per process, all running as an unprivileged user that owns the checkout.
WorkingDirectory is load-bearing rather than cosmetic. Every entrypoint
imports @workspace/env/load-root-env first, which walks up from the current
directory looking for the nearest .env, up to eight levels. Start the process
somewhere outside the checkout and it finds nothing. Variables already present
in the real environment always win over the file, so Environment= lines in
the unit override it rather than fighting it.
# /etc/systemd/system/shipkit-api.service
[Unit]
Description=ShipKit API
After=network-online.target postgresql.service redis-server.service
Wants=network-online.target
[Service]
Type=simple
User=app
WorkingDirectory=/srv/shipkit
Environment=NODE_ENV=production
Environment=PORT=4001
ExecStart=/home/app/.bun/bin/bun /srv/shipkit/apps/api/dist/index.js
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.targetThe Next apps are the same shape with
ExecStart=/home/app/.bun/bin/bun run next start --port <port> and
WorkingDirectory pointing at the app directory. Note the explicit --port:
each app's start script hardcodes its development port, and passing PORT in
the environment will not override an explicit flag in that script.
nginx
The admin panel and the API must be served from the same hostname.
packages/auth sets cookieSameSite: "lax", so an admin on a different origin
never receives its session cookie, and every login silently fails.
server {
listen 443 ssl;
server_name app.example.com;
location /api/ {
proxy_pass http://127.0.0.1:4001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
# Overwrite rather than append: the API rate limiter reads
# X-Forwarded-For[0], and an appended chain lets a client spoof it.
proxy_set_header X-Forwarded-For $remote_addr;
}
location / {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}Give the admin its own server block on its own hostname, with the same
/api/ location pointing at the same API port.
The variables that fail silently
These do not raise. They change behaviour, and the failure surfaces later as something that looks unrelated.
| Variable | What goes wrong when it is wrong |
|---|---|
NEXT_PUBLIC_APP_URL | Better Auth appends /api/auth itself. Point this at a URL that already ends in /api and every auth request goes to /api/api/auth/... and 404s. |
TRUSTED_ORIGINS | Feeds both the CORS allow-list and Better Auth's trusted origins. If the two disagree with the actual host, a correct password returns "invalid credentials". |
USE_SECURE_COOKIES | Must be true behind HTTPS. Left false, the session cookie is issued without Secure and browsers drop it on a cross-site navigation. |
BETTER_AUTH_SECRET | Changing it invalidates every existing session at once. Generate once, keep it. |
The full list is in the environment reference.
Redis
One Redis serves the cache and the queues, but they are configured differently
on purpose: the cache client sets enableOfflineQueue: false so a request path
fails fast when Redis is down, while the queue connections force it back on
because BullMQ's blocking commands need it. If you share one Redis between
several apps, give each its own REDIS_DB index rather than one namespace, so
a FLUSHDB in staging cannot empty production's queues.
Verifying a deploy
curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/en
curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/api/openapi
systemctl is-active shipkit-api shipkit-web shipkit-admin shipkit-workerThere is no /health endpoint. The API mounts @elysiajs/openapi, so
/api/openapi answering means the process is up and its routes are registered,
which is the closest thing shipped today. If you want a real liveness probe
that does not render a document, add one route and point your monitor at it.
A responding API with a dead worker is the failure worth naming: nothing user-facing breaks, and every queued email, webhook delivery and export quietly stops happening. Alert on the worker unit, not only on the HTTP surface.