ShipKit docs
Guides

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

ProcessWhat it isNeeds a port
apps/webNext.js, the product UIyes
apps/apiElysia, the REST APIyes
apps/adminNext.js, the admin panelyes
apps/workerBullMQ consumerno

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 build

The 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 helpers

drizzle-kit push does not create extensions, and it does not tell you that is the problem. It fails with:

error: type "vector" does not exist

Locally 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:push

See 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.target

The 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.

VariableWhat goes wrong when it is wrong
NEXT_PUBLIC_APP_URLBetter 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_ORIGINSFeeds 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_COOKIESMust be true behind HTTPS. Left false, the session cookie is issued without Secure and browsers drop it on a cross-site navigation.
BETTER_AUTH_SECRETChanging 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-worker

There 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.

On this page