Going live

A new site is set up to be tried, not to be used. It sends no email and takes pretend payments. This chapter walks through what to change before real visitors arrive: a checklist first, then email, payments, sign-in providers, the captcha, search engines and, if you want it, Postgres.

It assumes the site already runs on a server as described in Run it on a server. Where a change touches site.config.ts, you make it on your computer, commit and push it, then run git pull and docker compose up -d --build on the server. Settings that hold secrets go in .env on the server, which is never in git. As before, example.com stands for your domain.

Checklist

  • CMS_SITE_URL in .env is the public https address, with no slash at the end. Check what the site really uses with docker compose exec app printenv CMS_SITE_URL.
  • Email sends for real, and you sent yourself a test (see Email, below).
  • If you run a shop, payments go through Stripe and not through fakePayments(), and you placed a test order.
  • Your admin password is long and not used anywhere else. The Users screen lists only the people who should have access.
  • You decided whether visitors may sign themselves up. It is off until an admin turns it on under Settings → Users, with the setting called Allow sign-ups.
  • Backups run on a schedule, and you have tried a restore once (Backups and updates).
  • CMS_NOINDEX is not set on the live site, so search engines may list it. A staging copy should set it.
  • The page titles and descriptions say what you want search engines to show (Search engines).
  • You used the site yourself, on a phone as well: sent a form, signed in, asked for a password reset and, for a shop, bought something.

Email

A new site uses consoleEmail(). It sends nothing. It writes each message to a file inside the container and puts one line about it in the log. Until you set up a real provider, nothing the site emails reaches anyone: not password resets, and not order confirmations or form notifications either.

You can use SMTP, which any mail provider or mail server offers, or Amazon SES. List both and choose between them under Settings → Email if you like; the first one listed is the default. Both are separate packages, released with the cms at the same version.

SMTP

  1. On your computer, in the site folder, add the package.
pnpm add @jamiemcconnell/cms-email-smtp

Check that it landed at the same version as the cms:

pnpm list "@jamiemcconnell/*" --depth 0
  1. In site.config.ts, import it and use it in place of consoleEmail().
site.config.ts
import { defineConfig, localStorage, sqlite } from '@jamiemcconnell/cms';
import { smtp } from '@jamiemcconnell/cms-email-smtp';

export default defineConfig({
  // ...
  email: smtp(), // in place of consoleEmail()
});
  1. On the server, add the connection to .env. The address holds your provider's user name and password. If either contains a character such as @ or /, write it percent-encoded (@ becomes %40). Use smtps:// in place of smtp:// for a provider that wants a secure connection from the start, which is usually port 465.
.env
SMTP_URL=smtp://username:password@smtp.example.com:587

You can instead enter the host, port and secure setting under Settings → Email and keep only the credentials in .env, as SMTP_USER and SMTP_PASSWORD. SMTP_URL beats those settings when both are present. To fix the host in code, give smtp({ host: 'smtp.example.com', port: 587 }) the details.

  1. Commit and push the change, then update the server.
git pull
docker compose up -d --build
  1. Open Settings → Email in the admin. Fill in From name, From address and Reply-to address, with an address your provider allows you to send from. If you leave From address empty, mail comes from no-reply@ and your site's host name. Under “Send a test email to”, enter your own address and choose Send test.

Amazon SES

SES needs a verified sender address or domain in your AWS account. A new SES account starts in a sandbox, where it can send only to addresses you have verified, until AWS grants it production access. Do that in AWS before you go live.

  1. Add the package and use it in site.config.ts.
pnpm add @jamiemcconnell/cms-email-ses
site.config.ts
import { defineConfig, localStorage, sqlite } from '@jamiemcconnell/cms';
import { ses } from '@jamiemcconnell/cms-email-ses';

export default defineConfig({
  // ...
  email: ses(), // or [smtp(), ses()] to choose under Settings → Email
});
  1. On the server, add the credentials of an AWS user that is allowed to send email, and the region where you set up SES.
.env
AWS_ACCESS_KEY_ID=paste-the-access-key-id
AWS_SECRET_ACCESS_KEY=paste-the-secret-access-key
AWS_REGION=eu-west-2

Add AWS_SESSION_TOKEN only if you use temporary credentials. You can leave out AWS_REGION and enter the region under Settings → Email instead; the variable wins when both are set.

  1. Commit, push and update the server as for SMTP, then fill in the sender and send a test under Settings → Email.

Until you save a sender address, mail comes from CMS_EMAIL_FROM if that is set in .env, and otherwise from no-reply@ and your site's host name.

Payments

This section applies only if the site has the shop. A new shop uses fakePayments(), a pretend payment page whose Pay button always works. It marks orders as paid without any money moving. The site refuses to start with it on an https address, so you notice, unless CMS_ALLOW_FAKE_PAYMENTS=true is set. Never set that on a live shop.

Stripe is the provider to switch to for real payments. Customers pay on Stripe's own page, and Stripe tells your site when the payment arrived. Use Stripe's test mode first. Its test keys start with sk_test_, and the test card number 4242 4242 4242 4242 with any future date and any code pays successfully. Stripe renames things in its dashboard from time to time; the steps say what each one does.

  1. On your computer, add the package.
pnpm add @jamiemcconnell/cms-payments-stripe
  1. In site.config.ts, use stripe() in place of fakePayments() in the shop's payments.
site.config.ts
import { shop } from '@jamiemcconnell/cms-shop';
import { stripe } from '@jamiemcconnell/cms-payments-stripe';

export default defineConfig({
  // ...
  features: [
    shop({
      payments: [stripe()], // in place of [fakePayments()]
      currency: 'GBP',
      pricesIncludeTax: true,
      taxRate: 20,
    }),
  ],
});
  1. In the Stripe dashboard, find the API keys and copy the secret key.
  2. Add a webhook endpoint, which is the address where Stripe sends news of payments. Use your own domain with this path, and create the endpoint with the API version 2026-08-26.dahlia. Stripe sends the payload in the endpoint's version, and the cms expects that one.
Webhook endpoint URL
https://example.com/api/payments/webhooks/stripe
  1. Choose exactly these events for the endpoint:
  • checkout.session.completed
  • checkout.session.async_payment_succeeded
  • checkout.session.async_payment_failed
  • checkout.session.expired
  • refund.created
  • refund.updated
  1. Copy the endpoint's signing secret, which starts with whsec_. On the server, add both secrets to .env:
.env
STRIPE_SECRET_KEY=sk_test_paste-the-secret-key
STRIPE_WEBHOOK_SECRET=whsec_paste-the-signing-secret
  1. Commit, push and update the server. Place a test order with the test card. The order should show as paid in the admin.

When that works, repeat the webhook and the keys in Stripe's live mode and replace the test values in .env with the live ones.

Issue refunds from the order in the admin. A refund made in the Stripe dashboard carries no order information and the site ignores it.

Sign-in providers

Visitors and staff can sign in with Google or with an OpenID provider such as Pocket ID. You list the provider under auth.providers in site.config.ts: google() from @jamiemcconnell/cms-auth-google, or pocketId() and oidc() from @jamiemcconnell/cms-auth-oidc. Each puts a button on both sign-in pages. Accounts and sign-in explains how to set each one up, and Run it on a server lists the variables they read.

Captcha

Sign-in, sign-up and the forms are protected by a proof-of-work check that runs in the visitor's browser. It needs a secure context, which means https or localhost. On your live site with https it works without any change. A site served over plain http on some other address cannot run it and has to turn it off. Forms shows the setting.

Keep a staging copy out of search engines

If you run a second copy of the site to try changes before the live one, set CMS_NOINDEX in its .env:

.env
CMS_NOINDEX=true

Every page of that copy then asks crawlers to stay away, its robots.txt turns them all back, and the sitemap is no longer advertised. Leave the setting out of the live site. It is a request, not a lock, so a staging copy that must stay private also needs a sign-in or a firewall.

Storage

localStorage(), which a new site uses, keeps uploaded files on disk under the data directory, which is the data volume in Docker. Back that volume up, as Backups and updates describes.

Postgres

SQLite, the database a new site uses, is a single file and is enough for many sites. Move to Postgres if you want a separate database server, more than one copy of the site, or the backup tools that come with Postgres. It is optional.

The steps below were run with the site in Docker next to a Postgres 17 container. Uploads still live on the data volume, so you keep that and its backups.

  1. On your computer, add the Postgres driver, pg. Keep better-sqlite3, so that the site on your computer carries on with SQLite.
pnpm add pg
  1. In site.config.ts, use Postgres when DATABASE_URL is set and SQLite otherwise. postgres() reads the connection string from DATABASE_URL.
site.config.ts
import { consoleEmail, defineConfig, localStorage, postgres, sqlite } from '@jamiemcconnell/cms';

export default defineConfig({
  // ...
  database: process.env['DATABASE_URL'] ? postgres() : sqlite({ file: `${data}/site.db` }),
});
  1. On the server, give the database a password. Add it to .env with a generated value. Do this once; the command appends to the file.
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env
  1. In compose.yaml, under app:, add the connection string and make the site wait for the database:
compose.yaml, inside app:
  app:
    # ...the lines that are already there...
    environment:
      DATABASE_URL: postgres://cms:${POSTGRES_PASSWORD}@db:5432/cms
    depends_on:
      db:
        condition: service_healthy
  1. Add the database as a service of its own, beside app::
compose.yaml, beside app:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: cms
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: cms
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U cms -d cms']
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped
  1. Add the volume at the end, under volumes::
compose.yaml, at the end
volumes:
  data:
  pgdata:
  caddy_data:
  caddy_config:
  1. Commit and push the changes, pull them on the server and start the site. The site applies its migrations to the empty database on its first request.
git pull
docker compose up -d --build
  1. Check which database the site uses. The health report says "dialect":"postgres".
curl -s https://example.com/api/health

Then create your admin account and load the starter content, as in Run it on a server. A database that starts empty has nothing in it from before. Moving the content of an existing SQLite site across is a separate job that this chapter does not cover.

Back up Postgres with pg_dump; Backups and updates shows the commands.

Next

Set up backups and learn how to update the cms in Backups and updates.