Deploy the booking business
Put the website, the dashboard and the API online: Docker Compose on one server, one app per host from its Dockerfile, or plain Node.js, then connect your payment processor.
For the Full Stack package
Three ways to deploy
| Way | Good for | What you need |
|---|---|---|
| Docker Compose on one server | A VPS you control, all three apps together | The Full Stack package, Docker on the server, a reverse proxy for HTTPS |
| One app per host, from its Dockerfile | Railway, Render, Fly.io and other hosts that build from a Dockerfile | Each app's folder; a volume or MySQL and a bucket for the API |
| Plain Node.js | Any Node host | Node.js 22 or newer and Yarn 4 on the host |
Whichever way you choose, the website and the dashboard compile the API's address into their JavaScript when they are built, so build them with the public addresses, never the local ones.
Docker Compose on one server
Copy the package folder to the server and put a .env beside docker-compose.yml with the three public addresses. The API's allowed origins, the dashboard's address, the website's address a payment processor returns the guest to, the photograph addresses and the frontends' API address are all built from them.
SITE_URL=https://www.your-company.com
ADMIN_URL=https://admin.your-company.com
API_URL=https://api.your-company.com
MEDIA_HOSTNAME=api.your-company.comMEDIA_HOSTNAME is optional, one host or several comma separated (add your bucket's host when uploads go to one): photographs on those https hosts are resized by Next.js, and any other photograph loads as it is. Then build and start in the background:
docker compose up --build -d- Point each domain at the server through a reverse proxy that serves HTTPS (Caddy, Nginx or Traefik), forwarding to ports
3030,3031and8000. - Behind that proxy, set
TRUST_PROXY=1inback-end/.env, so the sign-in, booking, question and review limits count each visitor rather than the proxy. The API container readsback-end/.envwhen it exists, which is also where the payment, AI, R2 and MCP keys go. - Without a
JWT_SECRETof your own, the API generates one on its first start and keeps it in thehollina-datavolume. The database and the uploads live in that volume too, so back it up. - The first start seeds the demo company unless
SEED_DEMO_DATA=falseis in the.env. Keep it for the first start: the seed is what creates the accounts.
The API from its Dockerfile
Point the host at the API's folder (back-end/ in the Full Stack). The image listens on PORT (8000 unless the host sets another), answers GET /api/health once its database answers, and keeps SQLite and the uploads in /data.
- Mount a persistent volume at
/data. Without one, every deploy starts on an empty database. - Set
CORS_ORIGINto the website's and the dashboard's addresses (comma separated),FRONTEND_URLto the dashboard's,SITE_URLto the website's, andPUBLIC_MEDIA_URLto the API's public address followed by/media. - Set
SEED_DEMO_DATA=truefor the first start on a new volume: the image's default isfalse, which creates the tables with no accounts. An existing database is never seeded again. - Set your own
JWT_SECRET, or let the container generate one and keep it in/data/.jwt-secret. - Set
TRUST_PROXY=1: a hosting platform puts a proxy in front of the container. - On MySQL (
DB_TYPE=mysqland the fiveDB_*variables), the container does not create the tables. Runnode dist/database/seeder.js(demo company) ornode dist/database/sync-schema.js(empty tables) once with the same environment. - Uploads on the volume survive a redeploy; to keep them off the server, set the five
R2_*variables for a bucket.
The website and the dashboard from their Dockerfiles
Point the host at the app's folder and give it the NEXT_PUBLIC_* values as build arguments, not runtime variables: they are compiled in. Each image serves Next.js's standalone server on PORT (3030 for the website, 3031 for the dashboard, unless the host sets another).
| Build argument | App | Value |
|---|---|---|
NEXT_PUBLIC_API_BASE_URL | Both | The API's public address followed by /api |
NEXT_PUBLIC_SITE_URL | Both | The website's public address: canonical URLs and the sitemap on the website, "Back to website" and "View stay" in the dashboard |
NEXT_PUBLIC_MEDIA_HOSTNAME | Both | Optional. The https hosts serving the photographs (the API's domain, your bucket's, or both), comma separated |
NEXT_PUBLIC_WEBSOCKET_BASE_URL | Dashboard | The API's public address without /api, for live notifications |
The website's server also fetches from the API while it renders page titles, the first render of the home and stay pages, and the sitemap. With a public API address that works from the server too, nothing more is needed; API_INTERNAL_URL at run time overrides it when the server must use another address.
Plain Node.js
Build each app once on the host, then start it. Set the frontends' .env to the public addresses before yarn build.
| App | Build | Start |
|---|---|---|
| API | yarn build, then once yarn seed:prod (demo company) or yarn db:sync:prod (empty tables) | yarn start:prod |
| Stays website | yarn build | yarn start (port 3030) |
| Booking dashboard | yarn build | yarn start (port 3031) |
The API binds 0.0.0.0 and reads PORT. Keep the SQLite file and MEDIA_UPLOAD_DIR on storage that survives a deploy, or use MySQL and a bucket.
Take real payments
A fresh install settles card and PayPal bookings with the shipped simulator, which takes no money, until a processor is configured. Configure the ones your guests will pay with: with Stripe alone the website offers card, with PayPal alone PayPal, with both both, and pay on arrival beside them while it is on. In the API's environment:
- Stripe:
STRIPE_SECRET_KEY, and a webhook endpoint athttps://<your API>/api/webhooks/payments/stripeforcheckout.session.completed,payment_intent.payment_failed,charge.refundedandcharge.dispute.created, whose signing secret goes inSTRIPE_WEBHOOK_SECRET. Until it is set, every Stripe webhook is refused and a card booking never settles. - PayPal:
PAYPAL_CLIENT_ID,PAYPAL_CLIENT_SECRET,PAYPAL_ENV=live, and a webhook athttps://<your API>/api/webhooks/payments/paypalforPAYMENT.CAPTURE.COMPLETED,PAYMENT.CAPTURE.DENIED,PAYMENT.CAPTURE.REFUNDEDandPAYMENT.CAPTURE.REVERSED, whose id goes inPAYPAL_WEBHOOK_ID. SITE_URLat the website's public address: the processor sends the guest back to their own booking page there.- Pay on arrival is a switch in the dashboard's Payments tab, on by default.
Your first accounts
There is no sign-up page: the two dashboard accounts come from the seed. Start the first time with the demo company, sign in, then make it yours:
- Change both passwords under Settings, Security, or create your own Super Admin in Admin management and delete the seeded accounts.
- Return the demo stays to draft so the website no longer offers them. The dashboard has no delete for a stay, and the API deletes one only while none of its bookings holds nights, which a completed booking always does.
- Set the company's name, tagline, description, contact email, phone and address line under Settings, Company; the hero stay and the featured stays under Settings, Homepage; pay on arrival and the cancellation policy under Settings, Payments.
- Rename or replace the demo destinations under Settings, Destinations. A destination that still has stays cannot be deleted.
Going live
Before the booking business is reachable from the internet:
- In the API's environment,
NODE_ENV=productionand a long randomJWT_SECRETof your own (Docker generates one when none is set). In production the API refuses to start withoutCORS_ORIGIN, and says which value is missing. CORS_ORIGINat the website and the dashboard,FRONTEND_URLat the dashboard,SITE_URLat the website,PUBLIC_MEDIA_URLat the API followed by/media.- Stripe, PayPal or both configured with their webhooks. Until one is, the simulator confirms every card and PayPal booking without taking any money, and the API's start-up log says
Payment simulator active. - The database and the uploads on persistent storage, with a backup.
- The host's health check at
/api/health, andTRUST_PROXY=1behind a proxy. - Both frontends built with the public
NEXT_PUBLIC_*values. - On the website, your brand in
src/config/brand.config.ts, and the privacy policy inmessages/privacyrewritten for your company and your country. - The seeded passwords changed, or the seeded accounts deleted.
DEMO_MODE=falseon the API andNEXT_PUBLIC_DEMO_MODE=falsein both frontends, as shipped.
Not for a company with real data
yarn setup:fresh builds, drops every table and seeds, every time it runs, and yarn db:reset drops every table. Running yarn seed again writes the shipped stays, bookings and company settings back to their shipped values, though it keeps the rows you created. They suit a brand new environment, never a start or build command.