Installation and deployment
Deploy Subsweep on Vercel or your own server behind HTTPS, schedule the hourly job, understand how the schema is created, check health and update safely.
8 min readUpdated Oct 17, 2026Applies to Subsweep 0.1.0
On this page
This page takes Subsweep from a zip file to a working production install. Pick Vercel or your own server; the environment, the hourly job and the health check are the same for both.
Note
Read Requirements first. In particular: Postgres 13+, an hourly scheduler, HTTPS and outbound email.
Overview
- Create a Postgres database.
- Generate secrets and write your production environment.
- Build the app. The build needs that environment and a reachable database.
- Start it behind HTTPS.
- Schedule
/api/cron/tickonce an hour. - Check
/api/healthand sign up.
There is no separate migration step: the schema is created and updated automatically. See Database schema and updates.
Prepare the environment
Generate the secrets:
openssl rand -base64 32 # BETTER_AUTH_SECRET
openssl rand -hex 32 # ENCRYPTION_KEY (64 hex characters are used as the key directly)
openssl rand -base64 24 # CRON_SECRETThe production environment, with the five values the app cannot run without at the top:
# Required in production
DATABASE_URL=postgres://user:password@db-host:5432/subsweep
BETTER_AUTH_SECRET=...
BETTER_AUTH_URL=https://subs.example.com
ENCRYPTION_KEY=...
CRON_SECRET=...
# Strongly recommended
NEXT_PUBLIC_APP_URL=https://subs.example.com
# Outbound email: SMTP (shown) or RESEND_API_KEY + RESEND_FROM.
# Without it, production refuses email sign-up and magic links.
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=postmaster@example.com
SMTP_PASS=...
SMTP_FROM=Subsweep <no-reply@example.com>If any of the five required values is missing, npm run build fails with Missing production env: .... A server started without them logs the same message and answers every request, /api/health included, with a 500 error. Everything else is optional and listed on Configuration.
Two production defaults to know:
- Stripe. If you charge customers, also set
STRIPE_WEBHOOK_SECRETwith the other Stripe keys (Stripe setup). The webhook at/api/webhooks/striperefuses every event with a 503 until it is set, so plans never change after checkout. - Demo shop. It is off in production unless
DEMO_LOGIN_ENABLED=true. Leave it unset on a customer-facing install (The demo account).
Warning
Keep ENCRYPTION_KEY safe and separate from your database backups. It encrypts every stored bank, email, Slack and accounting token. If it changes or is lost, those connections stop working and have to be connected again.
Option A: Vercel
The package is set up for Vercel: vercel.json registers an hourly cron for /api/cron/tick.
- Create the database. Neon or Supabase both work. The app reads
DATABASE_URL, and also accepts the names Vercel's storage integrations create (POSTGRES_URL,POSTGRES_PRISMA_URL,DATABASE_URL_UNPOOLED,POSTGRES_URL_NON_POOLING, in that order). - Import the project into Vercel and add the environment above for Production. The build runs with these values and connects to the database, so they must be available at build time too.
- Set
CRON_SECRET. Vercel Cron sends it asAuthorization: Bearer <CRON_SECRET>. Do not also set a differentJOB_SECRET: when both exist,JOB_SECRETwins and Vercel's cron calls are refused. - Edit
vercel.json. It contains redirects fromwww.subsweep.apptosubsweep.app. Change them to your own domain or remove them. - Deploy, add your domain, then follow Check the deployment.
Important
Check your Vercel plan's cron and function limits. The hourly schedule needs a plan that allows hourly cron jobs (Vercel cron usage and pricing), and the whole hourly job runs inside one function call, so it must finish within your plan's maximum duration. Vercel also caps request bodies for functions (function limits), which can be lower than the app's 10 MB statement limit.
Attachments are written to local disk when Cloudflare R2 is not configured, and local disk does not persist on Vercel. See Known limitations.
Option B: Node on a VPS
A plain server with Node.js 22 and a Postgres you can reach.
# as a dedicated user, in /opt/subsweep
npm ci
nano .env.production.local # the environment from above
npm run build # needs the env file and the database
npm start # listens on port 3000Next.js reads .env.production.local by itself for both npm run build and npm start, so no extra loader is needed. Keep that file out of version control.
A systemd unit that binds to localhost only:
[Unit]
Description=Subsweep
After=network-online.target
[Service]
WorkingDirectory=/opt/subsweep
Environment=NODE_ENV=production
ExecStart=/opt/subsweep/node_modules/.bin/next start -H 127.0.0.1 -p 3000
Restart=always
User=subsweep
[Install]
WantedBy=multi-user.targetOption C: Docker
No Dockerfile ships with version 0.1.0; the bundled docker-compose.yml only starts a local Postgres for development. Because the build needs the production environment and the database, the simplest container builds when it starts rather than when the image is built. An example:
# Dockerfile (example, not included in the package)
FROM node:22-bookworm-slim
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3000
# The build needs DATABASE_URL and the other production values, so it runs at start-up.
CMD ["sh", "-c", "npm run build && exec npm start"]# .dockerignore
node_modules
.next
.data
.git
.env
.env.*
!.env.example# compose.production.yml (example)
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: subsweep
POSTGRES_PASSWORD: change-me
POSTGRES_DB: subsweep
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U subsweep"]
interval: 5s
retries: 10
app:
build: .
env_file: .env.production
environment:
DATABASE_URL: postgres://subsweep:change-me@db:5432/subsweep
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:3000:3000"
restart: unless-stopped
volumes:
pgdata:docker compose -f compose.production.yml up -d --build
docker compose -f compose.production.yml logs -f appEach container start rebuilds the app, which takes a minute or two and needs memory for the build. If you would rather build once, run the build in a CI step that can reach the database and copy the result into the image.
HTTPS and the reverse proxy
Subsweep needs no WebSockets or special headers; a standard reverse proxy is enough. Allow request bodies of at least 20 MB, because statements can be up to 10 MB and contract attachments up to 20 MB.
Caddy
Caddy obtains certificates automatically:
subs.example.com {
request_body {
max_size 25MB
}
reverse_proxy 127.0.0.1:3000
}nginx
server {
listen 443 ssl http2;
server_name subs.example.com;
ssl_certificate /etc/letsencrypt/live/subs.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/subs.example.com/privkey.pem;
client_max_body_size 25m;
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;
}
}
server {
listen 80;
server_name subs.example.com;
return 301 https://$host$request_uri;
}Schedule the hourly job
One endpoint does all background work: refreshes exchange rates, syncs active bank feeds (the last 48 hours of transactions), re-runs detection for every source, schedules and sends renewal reminders, sends manual charge checks, new-subscription, price and trial alerts, and sends digests at each workspace's chosen local hour.
- Paths:
/api/cron/tickand/api/jobs/tick(the same handler).GETandPOSTboth work. - Authentication:
Authorization: Bearer <secret>or anx-job-secret: <secret>header, where the secret isJOB_SECRETif set, otherwiseCRON_SECRET. - Frequency: once an hour. Digests and the monthly statement reminder are sent on every call made during the matching hour, so calling it more often sends them more than once.
On your own server, a crontab entry:
0 * * * * curl -fsS -m 300 -H "Authorization: Bearer YOUR_CRON_SECRET" https://subs.example.com/api/cron/tick > /dev/nullA wrong or missing secret returns 401 {"error":"Unauthorized"}; success returns {"ok":true}.
Database schema and updates
Subsweep has no migration files and no migrate command. The schema lives in lib/db/bootstrap.sql, and lib/db/index.ts holds a short list of additive patches. On the first database access in each server process, and during npm run build, the app runs both against your database:
- every table is created with
CREATE TABLE IF NOT EXISTS, so running it again is safe; - the patches only add missing columns and tables (
ADD COLUMN IF NOT EXISTS); - nothing is ever renamed, dropped or rewritten.
What this means for you:
- The database user needs permission to create tables in its schema.
- The server log shows harmless Postgres
NOTICElines such asrelation "user" already exists, skippingeach time a server process starts. - The vendor catalogue (534 vendors, 25 categories) is inserted the first time a workspace is created, and catalogue entries added in a later release are inserted on later runs.
Note
The package includes a drizzle.config.ts, but drizzle-kit is not a dependency and there is no migrations folder. You do not need it to install or update.
Check the deployment
curl https://subs.example.com/api/health{"ok":true}- It runs a real
select 1, so it also creates the schema if this is the first request. - It answers 503 with
{"ok":false}when the database cannot be reached, and 500 when a required production variable is missing (the whole server is down in that case). Use it as your host's health check: anything other than 200 is a failure.
Then:
- Sign up with your own address at
/signup. With email configured, confirm it from the verification email before you can sign in. Without email, production refuses email sign-up: configure email, use Google sign-in, or setALLOW_UNVERIFIED_SIGNUP=true(Configuration). - Check the sign-in page has no demo button, unless you set
DEMO_LOGIN_ENABLED=trueon purpose. See The demo account. - Run the hourly job once by hand with the curl line above and check the logs.
- If you use Stripe, check that
STRIPE_WEBHOOK_SECRETis set. Stripe's dashboard shows a 503 response for each webhook delivery while it is missing.
Updating to a new version
- Back up the database. Keep your environment file, and especially
ENCRYPTION_KEYandBETTER_AUTH_SECRET, unchanged. - Read the Changelog for anything that needs attention.
- Replace the code with the new release, keeping
.env.production.local(and.data/if you store attachments on local disk). - Run
npm ciandnpm run build. The build applies any new tables and columns. - Restart the app and check
/api/health.
If you changed the source, apply the new release on a branch and merge your changes before step 4.
Logs and backups
- Logs go to standard output and standard error. Lines from the app start with
[subsweep], for example[subsweep] Magic link for ...(development only) and[subsweep] email paused, not sent to .... - Back up the Postgres database on a schedule, and store
ENCRYPTION_KEYseparately. - Uploaded statements are not stored. They are parsed in memory; only the transactions they contain are saved.
- Attachments are stored in
.data/uploads/on local disk unless R2 is configured. Include that folder in your backups.