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.
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.fakePayments(), and you placed a test order.CMS_NOINDEX is not set on the live site, so search engines may list it. A staging copy should set it.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.
pnpm add @jamiemcconnell/cms-email-smtpCheck that it landed at the same version as the cms:
pnpm list "@jamiemcconnell/*" --depth 0site.config.ts, import it and use it in place of consoleEmail().import { defineConfig, localStorage, sqlite } from '@jamiemcconnell/cms';
import { smtp } from '@jamiemcconnell/cms-email-smtp';
export default defineConfig({
// ...
email: smtp(), // in place of consoleEmail()
});.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.SMTP_URL=smtp://username:password@smtp.example.com:587You 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.
git pull
docker compose up -d --buildno-reply@ and your site's host name. Under “Send a test email to”, enter your own address and choose Send test.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.
site.config.ts.pnpm add @jamiemcconnell/cms-email-sesimport { 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
});AWS_ACCESS_KEY_ID=paste-the-access-key-id
AWS_SECRET_ACCESS_KEY=paste-the-secret-access-key
AWS_REGION=eu-west-2Add 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.
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.
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.
pnpm add @jamiemcconnell/cms-payments-stripesite.config.ts, use stripe() in place of fakePayments() in the shop's payments.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,
}),
],
});2026-08-26.dahlia. Stripe sends the payload in the endpoint's version, and the cms expects that one.https://example.com/api/payments/webhooks/stripecheckout.session.completedcheckout.session.async_payment_succeededcheckout.session.async_payment_failedcheckout.session.expiredrefund.createdrefund.updatedwhsec_. On the server, add both secrets to .env:STRIPE_SECRET_KEY=sk_test_paste-the-secret-key
STRIPE_WEBHOOK_SECRET=whsec_paste-the-signing-secretWhen 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.
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.
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.
If you run a second copy of the site to try changes before the live one, set CMS_NOINDEX in its .env:
CMS_NOINDEX=trueEvery 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.
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.
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.
pg. Keep better-sqlite3, so that the site on your computer carries on with SQLite.pnpm add pgsite.config.ts, use Postgres when DATABASE_URL is set and SQLite otherwise. postgres() reads the connection string from DATABASE_URL.import { consoleEmail, defineConfig, localStorage, postgres, sqlite } from '@jamiemcconnell/cms';
export default defineConfig({
// ...
database: process.env['DATABASE_URL'] ? postgres() : sqlite({ file: `${data}/site.db` }),
});.env with a generated value. Do this once; the command appends to the file.echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .envcompose.yaml, under app:, add the connection string and make the site wait for the database: app:
# ...the lines that are already there...
environment:
DATABASE_URL: postgres://cms:${POSTGRES_PASSWORD}@db:5432/cms
depends_on:
db:
condition: service_healthyapp:: 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-stoppedvolumes::volumes:
data:
pgdata:
caddy_data:
caddy_config:git pull
docker compose up -d --build"dialect":"postgres".curl -s https://example.com/api/healthThen 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.
Set up backups and learn how to update the cms in Backups and updates.