1. Pre-launch review (no code) Listed what’s left before going live: testing, backups, monitoring, legal/GDPR, and a dry run on the VPS. Listed the features a barbershop system usually has and which of them you’re missing. Researched WhatsApp pricing, which led to the free route below. 2. Security cleanup This came out of that review. Deleted: /dev/scheduling-test (a test page that shipped), /api/debug/db-connect-test (a leftover debug route), and two files of unauthenticated booking and appointment actions (11 actions). One of them listed appointments with customer details to anyone. Audited: all 127 server actions. The only unguarded ones left are the intended public ones: availability lookups, cookie-based booking drafts, and the token-gated one-time installer. Tidied: removed the four now-dead booking schemas from schemas.ts. 3. Client import from a spreadsheet Entry points: an upload icon next to “+” on the Clients page, and a link on the Add Client page. It’s owner-only, checked again on the server. Flow: /staff/clients/import. Upload a CSV and check the guessed column mapping. Review the preview, then import in chunks with progress. A report lists problem rows by spreadsheet row number, with a download. CSV template: a visible “Download CSV template” button (headers only: First name, Last name, Email, Phone). Behavior: Each client gets a sign-in-ready account, the same as adding one by hand. Nobody is emailed. Existing emails are left untouched. Earlier guest bookings under the same email attach automatically. “Surname, Firstname” names are read the right way round. People sharing a phone number still import. Limits: an email is required for every row, CSV only (no .xlsx), up to 5,000 rows per file, and notes aren’t imported. 4. WhatsApp reminders (free, manual route) Button: a WhatsApp button next to Call and Email on four pages: the owner and barber appointment pages, “Current appointment”, and the client page. It opens WhatsApp with a ready-written reminder, including a “change it” link. Phone numbers: read as the business’s own country when typed without a country code. This uses the new libphonenumber-js dependency. A number WhatsApp can’t use means no button. Reminders list: a new /staff/reminders page lists tomorrow’s confirmed bookings in time order. Each row has a one-tap Send on WhatsApp. Tapping marks it “Sent 14:46”, with an Undo. The mark shows on the appointment pages too. Barbers see their own list; owners see the whole shop with barber names. Calendar link: “WhatsApp reminders · 3 to send tomorrow” on both calendar pages. Database: one additive migration, Appointment.whatsappReminderSentAt. Rescheduling clears it, since a reminder for the old time says nothing about the new one. Audit log: who marked or undid each reminder is recorded. Tests and verification Before today Now Unit tests 297 331 Integration tests 273 285 New unit tests: the CSV parser, import row logic, and WhatsApp link and phone handling. New integration tests: the import service and the reminders service. Checks: typecheck and lint are clean (the one old branding warning remains). Browser: I exercised each feature in a real browser against a throwaway MySQL 8, including the permission split between Leo and Marcus.
NextChair
Booking app as an alternative to Booksy
VPS deployment (multi-business)
Everything below runs on a fresh VPS with Docker and the Compose plugin already installed — no other prerequisite (no host Node.js/npm needed; every command either uses Docker itself or plain shell tools). Every code block is meant to be pasted as-is, in order.
0. One-time: clone the repo and generate your secrets
# STACKS_DIR later on must be this exact absolute path — every stack
# shells `docker compose` back out to the HOST's dockerd via a mounted
# socket, so the path only resolves to the same thing on both sides if
# it's cloned here and never moved.
git clone <your-repo-url> /opt/nextchair
cd /opt/nextchair
Generate the secrets you'll paste into the .env files in the steps
below. Run these now and keep the output somewhere safe (a password
manager) — you'll need each value again in a moment.
# MYSQL_ROOT_PASSWORD — goes into BOTH stacks/infra/.env and manager/.env
# (the same value in both places).
echo "MYSQL_ROOT_PASSWORD=$(openssl rand -hex 24)"
# MANAGER_SESSION_SECRET — manager/.env only.
echo "MANAGER_SESSION_SECRET=$(openssl rand -hex 32)"
# MANAGER_SETTINGS_ENCRYPTION_KEY — manager/.env only. Encrypts the
# SMTP/VAPID secrets you'll enter in the Manager's Settings page later.
# Losing/rotating this makes any already-saved secret there undecryptable,
# so store it as carefully as the other secrets on this page.
echo "MANAGER_SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)"
# MANAGER_PASSWORD_HASH — manager/.env only. Replace "your-password" with
# your chosen Manager login password first. Run via a throwaway container
# so this works with nothing but Docker installed on the host.
docker run --rm node:22-alpine sh -c \
"mkdir -p /tmp/gen && cd /tmp/gen && npm install bcryptjs --no-audit --no-fund >/dev/null 2>&1 && \
node -e \"const h=require('/tmp/gen/node_modules/bcryptjs').hashSync('your-password',12); console.log('MANAGER_PASSWORD_HASH=\\\"'+h.replace(/\\$/g,'\\$\\$')+'\\\"')\""
1. Shared infrastructure — network, MySQL and Caddy
docker network create next-chair-edge
cp .env.infra.example stacks/infra/.env
Edit stacks/infra/.env:
MYSQL_ROOT_PASSWORD— the first secret you generated above.MYSQL_HOST_PORT— set this to127.0.0.1:3306so MySQL is only exposed on the VPS loopback interface, not publicly. You can still connect to it remotely through an SSH tunnel.ACME_EMAIL— a real address you check (Let's Encrypt renewal notices).
docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
2. Build the shared application image
Every business (and no other step in this guide) runs this same image — you only rebuild it here, once, and again whenever you ship a code update (see "Updating" below).
docker build -t nextchair-app:latest .
3. The management UI (Manager)
This is what you'll use to add every business from here on.
cp manager/.env.example stacks/manager/.env
Edit stacks/manager/.env:
MANAGER_EMAIL— the email you'll log in with.MANAGER_PASSWORD_HASH— the bcrypt hash generated in step 0.MANAGER_SESSION_SECRETandMANAGER_SETTINGS_ENCRYPTION_KEY— the two other values generated in step 0.MANAGER_PUBLIC_URL—https://+ the domain you'll point at this app in the next step.MYSQL_ROOT_PASSWORD— same value asstacks/infra/.env.STACKS_DIR— same absolute path as step 1.
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
Point your chosen Manager domain's DNS (A/AAAA record) at the VPS, then give it a Caddy site and reload:
cat > stacks/infra/sites/manager.caddy << 'EOF'
manager.example.com {
reverse_proxy next-chair-manager:3000
}
EOF
docker exec next-chair-caddy caddy reload --config /etc/caddy/Caddyfile
(Replace manager.example.com with your real domain — Caddy issues its
TLS certificate automatically on first request.)
4. Configure platform-wide settings (once)
Log into the Manager at https://<your-manager-domain> and open
Settings. Both sections are optional to fill in now, but doing it
before adding your first business means that business never needs its
own SMTP/push setup:
- Email / SMTP — your SMTP relay's host/port/username/password and a from-address. Use Send Test Email to confirm it before saving for real, then Save Changes to apply it to every business.
- Push Notifications — click Generate VAPID Keys once (a contact email is enough to start). This is a one-time, platform-wide key pair — see that section's own copy for what happens if you ever regenerate it.
5. Add your first business
Still in the Manager: Add Business → name, slug, domain. This creates
its database, generates its secrets, starts its container, and runs its
migrations for you — no manual per-business .env editing needed.
Point that business's own domain's DNS at the VPS (the Manager doesn't do
this for you), then finish its setup by visiting
https://<business-domain>/install.
Repeat this step for each additional business, whenever you want one.
6. Notification delivery (required once, covers every business)
Staff invitation emails, appointment reminders, loyalty reward emails,
and Google Reviews sync are not sent automatically — the app only
queues them; something has to actually trigger delivery on a schedule by
calling two CRON_SECRET-gated endpoints
(/api/cron/process-notifications, /api/cron/sync-google-reviews — see
their own route files for the full reasoning). Skip this step and every
business's queued notifications just sit as PENDING forever — the UI
still says "an invitation email has been sent" the moment it's queued,
regardless of whether anything has actually delivered it yet.
stacks/infra/scripts/process-cron.sh
calls both endpoints for every business under STACKS_DIR in one
run, so a single crontab entry covers businesses added later too — no
per-business cron line to maintain.
Many minimal cloud VPS images (including some Debian ones) don't ship
cron at all — crontab -e failing with command not found means this
first:
sudo apt update && sudo apt install -y cron
sudo systemctl enable --now cron
Then add this to the VPS's crontab (crontab -e) once — not sudo crontab -e: run it as the same non-root user that already owns
/opt/nextchair (the one you've been running git pull/docker build
as), so the log file below is writable without needing root:
# Deliver queued notifications every 2 minutes.
*/2 * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /opt/nextchair/nextchair-cron.log 2>&1
# Refresh Google Reviews once a day.
0 3 * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh reviews >> /opt/nextchair/nextchair-cron.log 2>&1
(Adjust STACKS_DIR/the log path if you cloned this repo somewhere other
than /opt/nextchair, or don't have write access to it — /var/log/
itself is not a safe default here: it needs root, and a crontab
entry that can't write its own log file just silently produces no file
at all, which looks identical to "the job never ran.") To confirm it's
working, or to flush a notification that's stuck pending right now, run
either line's command by hand and check
/opt/nextchair/nextchair-cron.log — a healthy run logs <slug> process-notifications ok for every business.
Updating after a code change
For an existing NextChair server, do not re-run the fresh VPS setup above and do not overwrite your production .env files.
First pull the latest code:
cd /opt/nextchair
git pull
Before rebuilding, check whether either example environment file has gained new settings since your last deployment:
# Review only — do not copy these files over your existing production .env files.
diff -u stacks/infra/.env .env.infra.example || true
diff -u stacks/manager/.env manager/.env.example || true
If a new variable has been added, copy only that variable into the corresponding production .env and give it an appropriate value. Keep all existing passwords, secrets and generated values unchanged.
Then rebuild the one shared image every business uses:
cd /opt/nextchair
docker build -t nextchair-app:latest .
Then, for each business: use its Update button in the Manager UI, or run this directly:
cd /opt/nextchair/stacks/<slug>
docker compose --env-file .env up -d --force-recreate
Either way this recreates that business's container against the freshly
built image — and its own startup automatically runs
prisma migrate deploy, so any new database migration is applied on the
same step. No separate migration command needed.
If the code change touched the Manager app itself (anything under
manager/), rebuild/recreate the Manager too:
cd /opt/nextchair
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
If both the business app and Manager changed, do both update steps.
Upgrading from a version that included Dockge (one-time)
Dockge used to run alongside NextChair Manager as a separate stack management UI. It's no longer part of the shared infrastructure stack — Manager is the only tool needed to provision and manage business stacks, so the two were redundant. If your server was set up before this change, existing tenant databases, tenant containers, business Compose files, and the global SMTP/VAPID settings are all unaffected — this only removes the old Dockge container.
Pull the change, then re-apply the infra stack with --remove-orphans so
Docker removes the container that's no longer defined in
stacks/infra/compose.yaml:
cd /opt/nextchair
git pull
docker compose \
-f stacks/infra/compose.yaml \
--env-file stacks/infra/.env \
up -d --remove-orphans
--remove-orphans only removes containers Compose no longer recognizes
(the old next-chair-dockge container) — it does not touch Caddy, MySQL,
or their volumes.
This does not delete Dockge's own data volume
(next-chair-dockge-data) — Docker never removes volumes on its own, and
this command has no reason to either. Once you've confirmed Caddy, MySQL
and every business are still healthy, you can remove that now-unused
volume by hand:
docker volume rm next-chair-dockge-data
Finally, STACKS_DIR and DOCKGE_HOST_PORT in stacks/infra/.env were
only ever used by Dockge and are safe to delete from that file — nothing
else in the infra stack reads them. (Manager keeps its own separate
STACKS_DIR in stacks/manager/.env; leave that one alone.)