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
| Container | What it does | Reached at |
|---|---|---|
edge | Receives all traffic on ports 80 and 443, gets and renews the HTTPS certificates, sends each domain to the right container | the internet |
web | The web app your customers use | APP_DOMAIN, for example app.example.com |
api | The API the web app calls. Also receives the Stripe and Clerk webhooks | API_DOMAIN, for example api.example.com |
worker | Background jobs: processes the webhooks, applies plan limits | nothing, inside only |
migrate | Applies the pending database migrations on every deploy, then stops | nothing, inside only |
postgres | The database. Its data lives in a Docker volume | nothing, inside only |
redis | The job queue. Saved to disk every second | nothing, inside only |
backup | Saves the database every night into the backups folder, keeps the last 7 | nothing, inside only |
website | The 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:
| File | What it is |
|---|---|
packages/app-docker/Dockerfile | How the images are built |
packages/app-docker/docker-compose.prod.yml | Which containers run, and how they connect |
packages/app-docker/.env.production.example | Every production setting, explained. You copy it to .env.production |
packages/app-utils/prod-compose.sh | Runs Docker Compose on the production stack with your settings file |
packages/app-docker/backup/database-backup.sh | The nightly backup script, run by the backup container |
packages/app-utils/backup-database.sh, restore-database.sh | A 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, thensudo 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 noin/etc/ssh/sshd_config, thensudo 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:
timedatectlmust saySystem clock synchronized: yes.
Then get your code onto the server:
git clone <your repository address> app
cd appPoint the domains at the server
You need three addresses under a domain you own, for example:
| Setting | Example | Used for |
|---|---|---|
APP_DOMAIN | app.example.com | the web app |
API_DOMAIN | api.example.com | the API |
WEBSITE_DOMAIN | www.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:
dig +short app.example.comTip: 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:
cp packages/app-docker/.env.production.example packages/app-docker/.env.production
chmod 600 packages/app-docker/.env.production
nano packages/app-docker/.env.productionEvery 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:
openssl rand -hex 32Choose 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:
openssl rand -base64 32Keep 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:
- 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.
- 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 aclerk.example.comrecord). Add them and wait until Clerk shows them as verified; it can take up to 48 hours. - 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.
- Page API keys: copy the publishable key (
pk_live_...) intoCLERK_PUBLISHABLE_KEY, the secret key (sk_live_...) intoCLERK_SECRET_KEY, and the JWT public key intoCLERK_JWT_KEY, between double quotes, on several lines as in the example. - 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".
- Page Webhooks: add the endpoint
https://<API_DOMAIN>/webhooks/clerkwith these events, and copy its signing secret (whsec_...) intoCLERK_WEBHOOK_SIGNING_SECRET:
user.updated
user.deleted
organization.updated
organization.deleted
organizationMembership.created
organizationMembership.updated
organizationMembership.deletedWhich 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:
- 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. - In the Stripe Dashboard, Developers → Webhooks, add the endpoint
https://<API_DOMAIN>/webhooks/stripe, with API version2026-08-26.dahliaand the 18 events listed in Billing (subscriptions, invoices, disputes, refunds and fraud warnings). Put its signing secret inSTRIPE_WEBHOOK_SECRET. - Set
BILLING_PROVIDER=stripeand deploy (see "Deploy an update" below). - Create your plans in Stripe with the catalog command. Run it once, then again after every change of the plans in the code:
bash packages/app-utils/prod-compose.sh run --rm billing-sync-catalogNote: The catalog command only needs
BILLING_PROVIDER=stripeandSTRIPE_SECRET_KEY: it receives no webhooks. The API needsSTRIPE_WEBHOOK_SECRETtoo, and refuses to start without it whenBILLING_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.
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 --buildThen open:
| Address | What you should see |
|---|---|
| http://localhost:8180 | The web app, which sends you to the sign-in page |
| http://localhost:8181/healthz | {"status":"ok",...} |
| http://localhost:8182 | The 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 packages/app-utils/prod-compose.sh up -d --buildThe first time takes 5 to 15 minutes: the images are built on the server. Then check that everything runs:
bash packages/app-utils/prod-compose.sh ps --allYou 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>/healthzanswers{"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.
Find the other proxy's Docker network. List the networks of its container:
bashdocker inspect <proxy container> --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}'Put that name in
.env.production:SHARED_PROXY_NETWORK=<network>.Deploy with
--behind-proxy, and use it for every later command too (ps,logs, updates):bashbash packages/app-utils/prod-compose.sh --behind-proxy up -d --buildRoute your domains in the other proxy to
http://<folder>-prod-edge:80, with the originalHostheader andX-Forwarded-For. The certificates for your domains come from that proxy. For nginx, one block per domain, like this one for the web app:nginxserver { 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:
cd app
git pull
bash packages/app-utils/prod-compose.sh up -d --buildWhat happens:
- The images are rebuilt. Only what changed is rebuilt, so it is faster than the first time.
migrateapplies 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 withbash packages/app-utils/prod-compose.sh logs migrate.- 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:
docker image prune -fWarning: 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 (default03:15). The first deploy also makes one at once, so you have a backup from day one. - Where: in the
backupsfolder of theappfolder on the server, asbackups/database-<date>-<time>.dump. The files belong to the user who runsprod-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 psshowsbackupashealthywhen a backup less than 26 hours old exists,unhealthyotherwise. Its log has one line per backup:
bash packages/app-utils/prod-compose.sh logs backup2026-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 packages/app-utils/backup-database.shIt 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:
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 packages/app-utils/restore-database.sh backups/database-20260930-031500.dumpTo 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:
- loads the backup into a new database. If that fails, nothing has changed;
- stops the API, the worker and the nightly backups;
- puts the restored database in place of the current one, which is kept as
app_before_restoreuntil the next restore; - 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 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 healthIn 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:
{"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 thex-request-idheader (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>"'.jobNameandjobId: 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 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 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):
- Create a Sentry account and two projects: one Node.js project for the server and one React project for the web app.
- In
.env.production:EXCEPTION_HANDLER_DRIVER=sentry, the Node.js project's DSN inSENTRY_DSN, and the React project's DSN inVITE_SENTRY_DSN. A DSN is the address Sentry gives you in each project's settings, under Client keys (DSN). - Deploy (
up -d --build): the web app is rebuilt, because its DSN is built into it. - 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.
- Find the last good version and deploy it:
git log --oneline -10
git checkout <last good commit>
bash packages/app-utils/prod-compose.sh up -d --build- Undo the faulty change in the code, the normal way: a revert pull request, reviewed and merged into
main. - Put the server back on
mainand deploy:
git checkout main
git pull
bash packages/app-utils/prod-compose.sh up -d --buildWarning: 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):
- 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.
- Clerk webhook set up (
CLERK_WEBHOOK_SIGNING_SECRET), so that deleted people, members and workspaces reach your data. - Test keys replaced: production uses
pk_live_,sk_live_andrk_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. - 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. - Secrets: a random
POSTGRES_PASSWORD, anENCRYPTION_KEYmade withopenssl rand -base64 32and kept somewhere safe besides the server,.env.productionreadable by you only and never committed. - Server: only ports 22, 80 and 443 open, automatic security updates on, SSH sign-in with a key only.
- Backups:
backupshowshealthyinps, a heartbeat monitor watches it (BACKUP_HEARTBEAT_URL), thebackupsfolder is copied off the server regularly, and one restore was tested. - Error alerts on (Sentry), alert rules that email new issues of every level, and the emails reach someone who reads them.
- 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
backupsfolder until you copy them elsewhere (see "Copy the backups off the server").