Skip to content
Deploy

Deploy

Put Neatship in production on one server with Docker: choose the server, point three domains at it, fill in the production settings, deploy, update, keep nightly backups and copy them off the server, read the logs, turn on error alerts and roll back.

This guide puts your app in production on one rented server. Everything runs in Docker containers on that server: the web app, the API, the background worker, the database, Redis, the website, the nightly database backups, and a proxy that gets the HTTPS certificates by itself. Updating means three commands: get the new code, rebuild, restart. Database changes (migrations) are applied automatically on every deploy.

Read Security first: its checklist applies to production.

What runs on the server

ContainerWhat it doesReached at
edgeReceives all traffic on ports 80 and 443, gets and renews the HTTPS certificates, sends each domain to the right containerthe internet
webThe web app your customers useAPP_DOMAIN, for example app.example.com
apiThe API the web app calls. Also receives the Stripe and Clerk webhooksAPI_DOMAIN, for example api.example.com
workerBackground jobs: processes the webhooks, applies plan limitsnothing, inside only
migrateApplies the pending database migrations on every deploy, then stopsnothing, inside only
postgresThe database. Its data lives in a Docker volumenothing, inside only
redisThe job queue. Saved to disk every secondnothing, inside only
backupSaves the database every night into the backups folder, keeps the last 7nothing, inside only
websiteThe public website (for this starter: the page that sells it)WEBSITE_DOMAIN, for example www.example.com

Only edge can be reached from the internet. The database and Redis are never exposed.

The files involved:

FileWhat it is
packages/app-docker/DockerfileHow the images are built
packages/app-docker/docker-compose.prod.ymlWhich containers run, and how they connect
packages/app-docker/.env.production.exampleEvery production setting, explained. You copy it to .env.production
packages/app-utils/prod-compose.shRuns Docker Compose on the production stack with your settings file
packages/app-docker/backup/database-backup.shThe nightly backup script, run by the backup container
packages/app-utils/backup-database.sh, restore-database.shA backup now, and restore

Choose a server

Any Linux server where you can install Docker works: a virtual private server (VPS) from Hetzner, OVH, Scaleway, DigitalOcean or similar.

  • To start: 2 virtual CPUs, 4 GB of memory, 40 GB of disk. That is enough for your first thousands of users.
  • Memory matters for the build. The images are built on the server, and building the web app needs about 3 GB of memory. With a 2 GB server, add 2 GB of swap (a swap file) or the build can stop with an "out of memory" error.
  • Install Docker Engine with the Compose plugin, following Docker's instructions for your Linux distribution (docs.docker.com, "Install Docker Engine"). Check with docker compose version.
  • Open only three ports in the server's firewall: 22 (SSH), 80 and 443 (web). Port 80 is needed for the HTTPS certificates.
  • Keep the system up to date and sign in with a key. Turn on automatic security updates (on Ubuntu or Debian: sudo apt install unattended-upgrades, then sudo dpkg-reconfigure -plow unattended-upgrades; most providers' images already have it on). Sign in over SSH with a key, and turn password sign-in off (PasswordAuthentication no in /etc/ssh/sshd_config, then sudo systemctl restart ssh): most providers offer to add your key when you create the server.
  • Keep the clock on time. Sign-in tokens live 60 seconds, so a clock that drifts signs people out. Most servers sync it already: timedatectl must say System clock synchronized: yes.

Then get your code onto the server:

bash
git clone <your repository address> app
cd app

Point the domains at the server

You need three addresses under a domain you own, for example:

SettingExampleUsed for
APP_DOMAINapp.example.comthe web app
API_DOMAINapi.example.comthe API
WEBSITE_DOMAINwww.example.com (or example.com)the website

At your domain provider, add one A record per address, pointing to your server's IP address (and one AAAA record each if the server has an IPv6 address). Check that each one answers with your server's address before you deploy:

bash
dig +short app.example.com

Tip: Keep the three addresses under the same main domain as your Clerk production instance (next section). Clerk then accepts sign-ins from app.example.com without any extra setting.

Fill in the production settings

On the server, copy the example file and make it readable by you only:

bash
cp packages/app-docker/.env.production.example packages/app-docker/.env.production
chmod 600 packages/app-docker/.env.production
nano packages/app-docker/.env.production

Every setting is explained in the file. The ones that need work are below.

Warning: .env.production holds your secrets. It stays on the server: git ignores it, and it is never copied into an image. Don't paste it into a chat, a ticket or a commit.

Database password

Generate a long random password and paste it in POSTGRES_PASSWORD. Use letters and digits only, because it goes inside a connection address:

bash
openssl rand -hex 32

Choose it before the first deploy: the database is created with it.

Encryption key

The app can encrypt the secrets it stores for your customers (for example a token for another service they connect). The key that does it, ENCRYPTION_KEY, is required in production even before any feature uses it, so it already exists the day one does. Generate it once and paste it in ENCRYPTION_KEY:

bash
openssl rand -base64 32

Keep it like a password, and never change it by simply replacing it: what it encrypted could no longer be read. Changing it safely is described in Security, "Encrypt the secrets you store". Leave FALLBACK_ENCRYPTION_KEY empty.

Sign-in: a Clerk production instance

Production refuses the sample sign-in (AUTH_PROVIDER=mock) and your development keys must stay on your machine. In the Clerk dashboard:

  1. At the top, open Development, then Create production instance. Cloning the development settings copies your sign-in methods and the Organizations settings; check that Organizations is on with Membership required.
  2. Give it your main domain (for example example.com). On the Domains page, Clerk lists the DNS records to add at your domain provider (for example a clerk.example.com record). Add them and wait until Clerk shows them as verified; it can take up to 48 hours.
  3. Social sign-in (Google...) needs your own OAuth credentials in production: Clerk's shared development credentials don't work there. Each provider's page in Clerk explains how to get them.
  4. Page API keys: copy the publishable key (pk_live_...) into CLERK_PUBLISHABLE_KEY, the secret key (sk_live_...) into CLERK_SECRET_KEY, and the JWT public key into CLERK_JWT_KEY, between double quotes, on several lines as in the example.
  5. Page Protect → Rules: check that Bot sign-up protection says Enabled, and that the Lockout policy is on; turn on blocking disposable email addresses. A production instance has its own settings: check them even if you did in development. Details in Authentication, "Protect sign-up against bots".
  6. Page Webhooks: add the endpoint https://<API_DOMAIN>/webhooks/clerk with these events, and copy its signing secret (whsec_...) into CLERK_WEBHOOK_SIGNING_SECRET:
text
user.updated
user.deleted
organization.updated
organization.deleted
organizationMembership.created
organizationMembership.updated
organizationMembership.deleted

Which addresses Clerk accepts: the server only accepts sign-in tokens issued for https://<APP_DOMAIN>. That address is built from APP_DOMAIN by the stack, so there is nothing else to set. More in Authentication.

Payments: Stripe live keys

You can deploy first with BILLING_PROVIDER=none (the plans are shown, the upgrade buttons say "Soon"), and turn payments on later. When you are ready to take real payments:

  1. Activate your Stripe account and create a restricted live key with the permissions listed in Billing (including read access to Payment Disputes, and Charges and Refunds). Put it in STRIPE_SECRET_KEY.
  2. In the Stripe Dashboard, Developers → Webhooks, add the endpoint https://<API_DOMAIN>/webhooks/stripe, with API version 2026-08-26.dahlia and the 18 events listed in Billing (subscriptions, invoices, disputes, refunds and fraud warnings). Put its signing secret in STRIPE_WEBHOOK_SECRET.
  3. Set BILLING_PROVIDER=stripe and deploy (see "Deploy an update" below).
  4. Create your plans in Stripe with the catalog command. Run it once, then again after every change of the plans in the code:
bash
bash packages/app-utils/prod-compose.sh run --rm billing-sync-catalog

Note: The catalog command only needs BILLING_PROVIDER=stripe and STRIPE_SECRET_KEY: it receives no webhooks. The API needs STRIPE_WEBHOOK_SECRET too, and refuses to start without it when BILLING_PROVIDER=stripe, so keep step 2 before step 3.

The rest of going live with Stripe (business details, payment methods, retries, tax, what to do about disputes) is in Billing, section "Going live".

Try the production stack on your machine first

Before renting a server, you can run the exact production stack on your own computer, without domains or HTTPS ("localhost mode"). Use your Clerk development keys and BILLING_PROVIDER=none in .env.production on your machine.

bash
cp packages/app-docker/.env.production.example packages/app-docker/.env.production
# Fill in POSTGRES_PASSWORD, ENCRYPTION_KEY and your Clerk development keys (pk_test_...).
bash packages/app-utils/prod-compose.sh --localhost up -d --build

Then open:

AddressWhat you should see
http://localhost:8180The web app, which sends you to the sign-in page
http://localhost:8181/healthz{"status":"ok",...}
http://localhost:8182The website

bash packages/app-utils/prod-compose.sh --localhost down stops it and keeps the data; add -v to delete the data too.

First deploy

On the server, from the app folder:

bash
bash packages/app-utils/prod-compose.sh up -d --build

The first time takes 5 to 15 minutes: the images are built on the server. Then check that everything runs:

bash
bash packages/app-utils/prod-compose.sh ps --all

You should see migrate exited with code 0 (the migrations ran), api, postgres, redis, web, website and backup marked healthy (backup within about a minute), and worker and edge running. Then:

  • https://<APP_DOMAIN> opens the sign-in page. Create your account: it is the first real user.
  • https://<API_DOMAIN>/healthz answers {"status":"ok",...}.
  • https://<WEBSITE_DOMAIN> shows the website.

Tip: If a page doesn't answer over HTTPS, read the proxy's log: bash packages/app-utils/prod-compose.sh logs edge. The usual cause is a domain that doesn't point at the server yet, or port 80 closed: the certificate authority checks the domain through it.

Behind another reverse proxy

Use this when the server already runs other sites, behind a reverse proxy (nginx, Caddy, Traefik...) that holds ports 80 and 443 and their HTTPS certificates. The edge container can't take those ports, so it steps back:

  • it publishes no port and serves plain HTTP;
  • it joins the other proxy's Docker network under one unique name, <folder>-prod-edge (for a repository in /opt/my-app: my-app-prod-edge), and it is the only container of your stack on that network;
  • the other proxy keeps doing HTTPS, and sends your domains to it.

Each product deployed on the server has its own containers and data, named after its folder, so several products built from the starter can share one server.

  1. Find the other proxy's Docker network. List the networks of its container:

    bash
    docker inspect <proxy container> --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}'

    Put that name in .env.production: SHARED_PROXY_NETWORK=<network>.

  2. Deploy with --behind-proxy, and use it for every later command too (ps, logs, updates):

    bash
    bash packages/app-utils/prod-compose.sh --behind-proxy up -d --build
  3. Route your domains in the other proxy to http://<folder>-prod-edge:80, with the original Host header and X-Forwarded-For. The certificates for your domains come from that proxy. For nginx, one block per domain, like this one for the web app:

    nginx
    server {
        listen 443 ssl;
        server_name app.example.com;
        ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    
        # Resolved when a request comes, so nginx starts even while your stack is down.
        resolver 127.0.0.11 valid=30s;
        set $my_app http://my-app-prod-edge:80;
    
        location / {
            proxy_pass $my_app;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }

    With Caddy: app.example.com, api.example.com { reverse_proxy my-app-prod-edge:80 }.

The edge still adds the security headers and routes each domain to its container. It trusts the X-Forwarded-For header only from private Docker addresses, so the API still sees each visitor's own address for its rate limits.

Tip: If the other proxy is installed directly on the server rather than in a container, it can't reach a container by name. Add ports: ['127.0.0.1:8280:80'] under edge in packages/app-docker/docker-compose.prod.behind-proxy.yml, point the proxy at http://127.0.0.1:8280, and set SHARED_PROXY_NETWORK to a network you create once with docker network create shared-proxy.

Deploy an update

Once your change is merged into main:

bash
cd app
git pull
bash packages/app-utils/prod-compose.sh up -d --build

What happens:

  1. The images are rebuilt. Only what changed is rebuilt, so it is faster than the first time.
  2. migrate applies the new migrations, in one transaction. If one fails, none is kept, the API and the worker keep running their previous version, and the command reports the failure. Read it with bash packages/app-utils/prod-compose.sh logs migrate.
  3. The API, the worker, the web app and the website are replaced by their new version. The worker lets running jobs finish first (up to one minute). The API is unavailable for a few seconds while it restarts.

Once in a while, delete the old images to free disk space. On a server that only runs this app:

bash
docker image prune -f

Warning: Never change production by hand: no manual SQL, no one-off script, no edited file on the server other than .env.production. A data fix or a one-time operation is a command or a migration in the code, reviewed and deployed like any other change. The catalog command above is an example: add your own commands as services with profiles: [commands] in docker-compose.prod.yml, next to billing-sync-catalog. Reading is fine: logs, ps, read-only queries.

Backups and restore

Nightly backups, automatic

The backup container saves the whole database every night, while the app keeps running:

  • When: every day at BACKUP_TIME, in UTC (default 03:15). The first deploy also makes one at once, so you have a backup from day one.
  • Where: in the backups folder of the app folder on the server, as backups/database-<date>-<time>.dump. The files belong to the user who runs prod-compose.sh, and only that user can read them.
  • How many: the newest BACKUP_KEEP_COUNT (default 7, a week); older ones are deleted after each successful backup. A failed backup never deletes anything.
  • Is it working? bash packages/app-utils/prod-compose.sh ps shows backup as healthy when a backup less than 26 hours old exists, unhealthy otherwise. Its log has one line per backup:
bash
bash packages/app-utils/prod-compose.sh logs backup
text
2026-10-01T03:15:00Z Backup saved: backups/database-20261001-031500.dump (2.4M).
2026-10-01T03:15:00Z Deleted the old backup backups/database-20260924-031500.dump (the newest 7 are kept).

A failure writes Backup FAILED with the reason just above it.

To be told when backups stop, use a heartbeat monitor: a free service (Healthchecks.io, Better Stack, Sentry Crons and others) gives you an address to call after each backup, and emails you when a day passes without a call. Put that address in BACKUP_HEARTBEAT_URL and deploy.

To make a backup right now (before a risky change, for example):

bash
bash packages/app-utils/backup-database.sh

It uses the same script and writes to the same folder. It counts among the 7 kept: copy it elsewhere if you need it longer.

Redis is not backed up: it only holds jobs waiting to run. The webhook events themselves are saved in the database.

Copy the backups off the server

Warning: A backup on the server's own disk is lost with the server. Copy the backups folder to another place regularly. The files contain all your customers' data: keep the copy private, on an encrypted disk or private storage.

From your own computer (macOS, Linux, or Windows with WSL), this copies the new backups of the server into a folder of your computer, and keeps the older copies you already have:

bash
mkdir -p ~/backups/my-app
rsync -av <you>@<server address>:app/backups/ ~/backups/my-app/

Replace <you> with the user you deploy with, <server address> with the server's IP address or name, and app with the folder where the code is, if you named it otherwise. Run it once a week at least, or schedule it on your computer. Without rsync, scp '<you>@<server address>:app/backups/*.dump' ~/backups/my-app/ does the same.

Your hosting provider's automatic server snapshots (a paid option at most providers) are a good second copy, not a replacement: they are lost if you lose the account.

Restore

bash
bash packages/app-utils/restore-database.sh backups/database-20260930-031500.dump

To restore a copy from your computer, first put it back in the server's backups folder (scp ~/backups/my-app/database-20260930-031500.dump <you>@<server address>:app/backups/).

The script asks you to type restore, then:

  1. loads the backup into a new database. If that fails, nothing has changed;
  2. stops the API, the worker and the nightly backups;
  3. puts the restored database in place of the current one, which is kept as app_before_restore until the next restore;
  4. starts everything again, migrations first, so an older backup is brought up to date.

Everything written after the backup was made is lost. Subscriptions are corrected by Stripe's next event for each customer.

Tip: Test a restore now and then, in localhost mode on your machine: copy a backup into your local backups folder, then bash packages/app-utils/restore-database.sh --localhost <file>. A backup you never restored is a hope, not a backup.

Logs

bash
bash packages/app-utils/prod-compose.sh logs -f api worker      # follow the API and the worker
bash packages/app-utils/prod-compose.sh logs --since 1h api      # the last hour of the API
bash packages/app-utils/prod-compose.sh logs migrate             # the last migration run
bash packages/app-utils/prod-compose.sh logs backup              # the nightly backups
bash packages/app-utils/prod-compose.sh ps --all                 # what runs, and its health

In production, the API and the worker write one JSON object per line, so log tools can sort and filter them. Each line has the time (timestamp, in UTC), the level (log, warn, error), the context (the part of the code that wrote it), the message, the stack of an error, and what was being handled:

json
{"timestamp":"2026-10-01T08:22:52.512Z","level":"log","context":"BullMqDriver","message":"Cron WebhookInboxRecoveryCronJob scheduled: */10 * * * * (UTC)."}
{"timestamp":"2026-10-01T09:14:03.120Z","level":"warn","context":"BillingSubscriptionSyncService","message":"No workspace has the Stripe customer cus_123: nothing to sync.","jobName":"ProcessStripeWebhookEventJob","jobId":"42"}
  • requestId: every API request gets an id, returned to the browser in the x-request-id header (visible in the browser's developer tools, Network tab). When a customer reports a problem, ask for it, then find every line of that request: ... logs api | grep '"requestId":"<the id>"'.
  • jobName and jobId: the background job that wrote the line.
  • The logs never contain passwords, tokens, request bodies or webhook contents: only what the code writes on purpose.

To read them as plain lines, install jq once (sudo apt install jq on Ubuntu or Debian), then:

bash
bash packages/app-utils/prod-compose.sh logs --no-log-prefix api worker \
  | jq -rR 'fromjson? | "\(.timestamp) \(.level) [\(.context // "")] \(.message)"'

Every server error is written as a line with the message Server fault: ..., and every alert (a payment dispute, a webhook event that keeps failing) as Alert: ..., with the workspace it concerns, so you can search for them:

bash
bash packages/app-utils/prod-compose.sh logs api worker | grep -E "Server fault|Alert:"

Logs are rotated automatically (5 files of 20 MB per container), so they never fill the disk. On your machine (yarn start), lines stay readable, in color.

Error alerts

By default, errors and alerts only go to the logs. To be told by email when something breaks, or when a customer disputes a payment, connect Sentry (a free plan exists):

  1. Create a Sentry account and two projects: one Node.js project for the server and one React project for the web app.
  2. In .env.production: EXCEPTION_HANDLER_DRIVER=sentry, the Node.js project's DSN in SENTRY_DSN, and the React project's DSN in VITE_SENTRY_DSN. A DSN is the address Sentry gives you in each project's settings, under Client keys (DSN).
  3. Deploy (up -d --build): the web app is rebuilt, because its DSN is built into it.
  4. In Sentry, check the alert rules of the Node.js project (Alerts): you want an email when a new issue appears, whatever its level, because payment alerts that need no immediate action are sent as warnings.

What is sent:

  • Server errors: errors that answer "Internal Server Error" (500), unknown errors, and every failed attempt of a background job. Each one says which workspace it happened in, where (the API operation or the job name) and the request id.
  • Server alerts, things you must know although nothing crashed, each with what to do. As errors: a payment disputed, a dispute lost, an early fraud warning, and a webhook event set aside after failing for hours (see Background jobs and webhooks). As warnings: a dispute won, a payment refunded. Payment alerts link to the Stripe Dashboard; what to do about them is in Billing, "Disputes, refunds and fraud warnings".
  • Web app: crashes (the "Something went wrong" screen) and API errors that no screen expects, including a server that can't be reached.

What is never sent: expected answers (signed out, not found, not allowed, invalid input), request headers and bodies, cookies, and personal data such as names, emails or IP addresses. Without VITE_SENTRY_DSN, the web app doesn't even download Sentry's code.

Roll back

If a deploy breaks something, first put the previous version back, then fix calmly.

  1. Find the last good version and deploy it:
bash
git log --oneline -10
git checkout <last good commit>
bash packages/app-utils/prod-compose.sh up -d --build
  1. Undo the faulty change in the code, the normal way: a revert pull request, reviewed and merged into main.
  2. Put the server back on main and deploy:
bash
git checkout main
git pull
bash packages/app-utils/prod-compose.sh up -d --build

Warning: Going back to an older version of the code doesn't undo a migration that already ran. Migrations are written so the previous version keeps working (a new column is added as optional first), so this is usually fine. If a migration itself must be undone, write a new migration that reverses it and deploy it like any change. If data was damaged, restore the last good backup.

Security headers

The web app and the website send the usual protective headers: HTTPS only (Strict-Transport-Security), no framing by other sites, no content-type guessing, and a Content Security Policy (CSP). A CSP is a list, sent with each page, of the only places the browser may load scripts from and send requests to. If an attacker ever manages to inject a script into a page, the browser refuses to run it or to send data elsewhere.

  • Web app: scripts from the app itself and from Clerk (your Clerk address, and Cloudflare's bot check that Clerk uses on sign-up); requests to your API, Clerk and, when error alerts are on, Sentry. The list is written when the image is built, from the same values as the app (packages/app-docker/scripts/write-web-content-security-policy.mjs), so it always matches your Clerk instance. Stripe needs nothing here: Checkout and the billing portal open on Stripe's own pages.
  • Website: scripts, styles, images and requests from the website itself only (packages/app-docker/website/Caddyfile).

Tip: If you add a service that loads a script or calls an address from the browser (analytics, a chat widget), add its address to the policy in those two files, or the browser blocks it. The browser's console names what it blocked.

Production checklist

Before opening to real customers (the complete list, settings included, ends Security):

  1. Clerk production instance created, its DNS records verified, social sign-in with your own OAuth credentials, Organizations on with "Membership required", the application name set to your product's name, and on Protect → Rules: Bot sign-up protection enabled, the lockout policy on, disposable email addresses blocked.
  2. Clerk webhook set up (CLERK_WEBHOOK_SIGNING_SECRET), so that deleted people, members and workspaces reach your data.
  3. Test keys replaced: production uses pk_live_, sk_live_ and rk_live_ keys only. Any key that has been pasted in a chat, a ticket, a screenshot or a commit is rotated: create a new one, delete the old one.
  4. Stripe (when you take payments): account activated, public business details and branding set, restricted live key (with read access to Payment Disputes, and Charges and Refunds), webhook endpoint with its 18 events, plans created with billing-sync-catalog, payment methods and retries chosen, customer emails on, tax registration and automatic tax set up as described in Billing.
  5. Secrets: a random POSTGRES_PASSWORD, an ENCRYPTION_KEY made with openssl rand -base64 32 and kept somewhere safe besides the server, .env.production readable by you only and never committed.
  6. Server: only ports 22, 80 and 443 open, automatic security updates on, SSH sign-in with a key only.
  7. Backups: backup shows healthy in ps, a heartbeat monitor watches it (BACKUP_HEARTBEAT_URL), the backups folder is copied off the server regularly, and one restore was tested.
  8. Error alerts on (Sentry), alert rules that email new issues of every level, and the emails reach someone who reads them.
  9. Website: its placeholder contact email and legal links replaced (see packages/app-website/README.md).

What a one-server setup means

These are choices of this setup, made to keep it simple and cheap. None of them stops you from running safely:

  • One server. If it goes down, the app is down until it's back. Backups copied elsewhere let you rebuild it on a new server.
  • A few seconds of interruption per deploy, while the API restarts.
  • Images are built on the server, not in CI. CI checks that they still build, on every pull request and every push to main.
  • Redis has no password. It is only reachable by the containers of the stack, on their private network.
  • Backups don't leave the server by themselves. The nightly backups stay in the backups folder until you copy them elsewhere (see "Copy the backups off the server").