This chapter puts your site on a server of your own, at your own domain, with https. You install Docker on a Linux server, copy your site to it and start it with one command. At the end, your domain shows your site behind a padlock, the certificate renews itself, and the site comes back by itself after the server restarts.
Allow about an hour the first time, including waiting for DNS and for the first build. If you would rather put Cloudflare, Pangolin or another proxy in front of the site, read this chapter first and then Behind a proxy, which shows what to change in .env.
The examples use example.com for your domain, you for your user name on the server, 203.0.113.10 for the server's address and my-site for the site's folder. Replace them with your own wherever you see them.
sudo on. The commands here are for Ubuntu 24.04 LTS. Other recent Ubuntu or Debian releases are close. Any virtual server from a hosting company will do.Memory matters while the site is being built. The site's build step used about 1.5 GB on a test machine, so a server with 2 GB or more is a safer start. With 1 GB, add swap first; Troubleshooting shows how.
In your domain's DNS settings, add an A record that points your domain at the server's IPv4 address:
| Type | Name | Value |
|---|---|---|
| A | example.com (some DNS panels want @) | 203.0.113.10 |
| AAAA, only if the server has IPv6 | example.com | the server's IPv6 address |
Only add the AAAA record if you know the server answers on that IPv6 address. An address that does not answer can make visitors, and the certificate check, slow or fail.
If your DNS is at Cloudflare, set the record's proxy status to “DNS only” (grey cloud) for now. The orange cloud is covered in Behind a proxy.
From your computer, check that the name now points at the server:
dig +short example.comIt should print the server's address. DNS changes can take from a few minutes to a few hours to spread. If dig is not installed, nslookup example.com does the same job.
Wait until this works before you start the site. If Caddy, the web server in front of your site, asks for a certificate while the name points somewhere else, the request fails, and Let's Encrypt limits how often you may try again.
Docker runs your site in a container, a packaged copy of the site and everything it needs. Compose, a part of Docker, starts the site's containers from compose.yaml.
ssh you@203.0.113.10docker compose command. They follow Docker's instructions for Ubuntu, which are worth a look in case they have changed.sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginsudo docker run --rm hello-worldsudo. The docker group is allowed to control Docker, which is as much power as root has on this machine, so only add people you trust. If you prefer, skip this and put sudo in front of every docker command from here on.sudo usermod -aG docker $USERexitssh you@203.0.113.10sudo. It prints a version number.docker compose versionDocker starts when the server boots. Together with restart: unless-stopped in compose.yaml, that is what brings your site back after a restart.
Ubuntu has a firewall called ufw, which is off until you turn it on. Allow SSH first. If you turn the firewall on without it, you lock yourself out of the server.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable
sudo ufw statusPort 80 is plain http. Caddy uses it to prove to Let's Encrypt that you own the domain, and to send visitors on to https. Port 443 is https, over TCP and over UDP for HTTP/3. ufw enable asks for confirmation; answer y.
If your hosting company has a firewall of its own, usually called a security group or cloud firewall in its control panel, open the same ports there.
Docker adds its own firewall rules for every port a container publishes, and ufw does not see them. Here that does no harm, since 80 and 443 are the ports you want open and the site itself publishes nothing. Do not count on ufw to hide a port that you publish yourself.
The best way to move the site is a private git repository. You push from your computer, the server pulls, and every change after this is git pull and one command. If you would rather not use git, there is an alternative at the end of this section.
cd my-site
git init -b main
git add .
git commit -m "First version"The folder's .gitignore already leaves out .env (your secrets), data/ (your local database) and node_modules/. It keeps pnpm-lock.yaml, which the server's build needs. If git asks who you are, answer with git config --global user.name "Your Name" and git config --global user.email you@example.com, then commit again.
git remote add origin git@github.com:you/my-site.git
git push -u origin mainssh-keygen -t ed25519 -C "my-site server" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pubgit clone git@github.com:you/my-site.git
cd my-siteCopy the folder from your computer with rsync instead. Run this in the site folder on your computer. It skips what .gitignore skips, and you run it again to send changes.
rsync -av --exclude-from=.gitignore --exclude=.git ./ you@203.0.113.10:my-site/The folder already holds the files that describe how the site runs on a server. You do not need to change them, but it helps to know what they do. This is compose.yaml, as the create command writes it:
# docker compose up --build → http://127.0.0.1:4321 (admin at /admin)
#
# Caddy (Caddyfile) fronts the app: compression, browser caching and, if you want it, https.
# One line in .env picks which Caddy runs, COMPOSE_PROFILES:
#
# proxy caddy-proxy: plain HTTP on ${CMS_BIND:-127.0.0.1}:4321. For trying the site on this
# machine, and for a server behind Cloudflare, Pangolin or another proxy that handles https.
# https caddy-https: Caddy gets the certificate for CMS_SITE_URL itself, on ports 80 and 443.
#
# A tunnel connector is one more profile beside it: COMPOSE_PROFILES=proxy,cloudflare or
# proxy,pangolin (the cloudflared and newt services below). It reaches caddy-proxy over this
# compose network, so it publishes nothing. .env.example lists every setting.
#
# The app only exposes 4321 to Caddy. The `browser` service (Playwright, for MCP
# screenshots) publishes nothing either.
#
# Changed COMPOSE_PROFILES on a running site? `docker compose up -d` alone leaves the old Caddy
# running, because compose only looks after the profiles that are on. Remove the old ones first:
# docker compose rm -sf caddy-https caddy-proxy cloudflared newt
# docker compose up -d
# The app restarts too, since it reads .env: about twenty seconds, and the data is untouched.
#
# The data volume starts empty. Seed it and create the first admin once:
# docker compose run --rm app pnpm cms seed --starter
# docker compose run --rm app pnpm cms user:create-admin --email you@example.com --password '…'
# What both Caddy services share. Only the address Caddy serves and the ports differ.
x-caddy: &caddy
image: caddy:2-alpine
depends_on:
app:
condition: service_healthy
restart: unless-stopped
services:
app:
build:
context: .
image: my-site
env_file: .env # CMS_AUTH_SECRET, CMS_SITE_URL
expose:
- '4321'
volumes:
- data:/data # the SQLite file and uploaded media
restart: unless-stopped
# COMPOSE_PROFILES=https. Caddy gets and renews the certificate for CMS_SITE_URL (https://…),
# which needs ports 80 and 443 free and the domain pointing at this server. The certificates
# live on the caddy_data volume. Caddy is not given `env_file: .env`, so CMS_AUTH_SECRET and
# the other secrets stay in the app.
caddy-https:
<<: *caddy
profiles: [https]
ports:
- '${CMS_HTTP_PORT:-80}:80'
- '${CMS_HTTPS_PORT:-443}:443'
- '${CMS_HTTPS_PORT:-443}:443/udp' # HTTP/3
environment:
CADDY_ADDRESS: ${CMS_SITE_URL}
CMS_TRUSTED_PROXIES: ${CMS_TRUSTED_PROXIES:-private_ranges}
CMS_CLIENT_IP_HEADERS: ${CMS_CLIENT_IP_HEADERS:-X-Forwarded-For}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
# COMPOSE_PROFILES=proxy. Plain HTTP only, no certificate: the address below is this machine
# unless CMS_BIND=0.0.0.0 lets another machine's proxy reach it.
caddy-proxy:
<<: *caddy
profiles: [proxy]
ports:
- '${CMS_BIND:-127.0.0.1}:4321:80'
environment:
CADDY_ADDRESS: ':80'
CMS_TRUSTED_PROXIES: ${CMS_TRUSTED_PROXIES:-private_ranges}
CMS_CLIENT_IP_HEADERS: ${CMS_CLIENT_IP_HEADERS:-X-Forwarded-For}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# COMPOSE_PROFILES=proxy,cloudflare. A Cloudflare Tunnel: set CLOUDFLARE_TUNNEL_TOKEN in .env,
# and in Cloudflare point the tunnel's public hostname at http://caddy-proxy:80.
cloudflared:
image: cloudflare/cloudflared:2026.10.0
profiles: [cloudflare]
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-}
depends_on:
caddy-proxy:
condition: service_started
required: false
restart: unless-stopped
# COMPOSE_PROFILES=proxy,pangolin. A Pangolin tunnel (Newt): set PANGOLIN_ENDPOINT, NEWT_ID
# and NEWT_SECRET in .env, and in Pangolin point the resource at caddy-proxy, port 80, over HTTP.
newt:
image: fosrl/newt:1.18.1
profiles: [pangolin]
environment:
PANGOLIN_ENDPOINT: ${PANGOLIN_ENDPOINT:-}
NEWT_ID: ${NEWT_ID:-}
NEWT_SECRET: ${NEWT_SECRET:-}
depends_on:
caddy-proxy:
condition: service_started
required: false
restart: unless-stopped
volumes:
data:
caddy_data:
caddy_config:Your copy ends with a commented-out browser service, which is only for screenshots when you connect an AI assistant. It is left out above, and AI assistants (MCP) explains it.
Compose starts these services. Which ones depends on COMPOSE_PROFILES in .env.
app is your site. Docker builds it from the Dockerfile in the folder. It reads the settings in .env and keeps its database and uploaded files on the data volume. Nothing outside Docker can reach it directly; Caddy passes requests on to it.caddy-https is the web server in front of the site when you run in https mode. Caddy gets a certificate for the address in CMS_SITE_URL from Let's Encrypt, renews it before it expires and sends http visitors to https. It also compresses pages and sets the headers that let browsers cache files. It publishes ports 80 and 443, and starts only after the site reports that it is healthy.caddy-proxy, cloudflared and newt are for the other setups in Behind a proxy. They do not run unless COMPOSE_PROFILES lists them.There are three volumes, which Docker keeps on the server's disk. data holds your database and uploads, so it is the one that matters. caddy_data holds the certificates and Caddy's account with Let's Encrypt. Keep it: if it is deleted, Caddy asks for new certificates, and Let's Encrypt limits how many it hands out. caddy_config holds a copy of Caddy's settings.
This is the Caddyfile, Caddy's own settings file. It serves both setups. Compose gives it the address to serve in CADDY_ADDRESS: your CMS_SITE_URL in https mode, and :80 in proxy mode.
# Caddy sits in front of the app. It compresses responses, sets the browser cache headers
# and, in https mode, gets and renews the site's certificate.
#
# This one file serves both setups. compose.yaml starts the Caddy that COMPOSE_PROFILES (in
# .env) picks and gives it CADDY_ADDRESS to match:
#
# https CADDY_ADDRESS is CMS_SITE_URL, such as https://example.com. Caddy fetches a
# certificate for that name, renews it, and redirects http:// to https://.
# proxy CADDY_ADDRESS is :80. Caddy serves plain HTTP and leaves https to whatever sits in
# front of it (Cloudflare, Pangolin, another proxy), or to nobody when you are only
# trying the site on your own computer. An address that is just a port never turns
# on automatic https, so there is nothing to switch off.
{
admin off
# A proxy in front of Caddy tells it the visitor's address and whether they used https in
# headers. Caddy believes those headers only from the addresses listed here. The default is
# the private ranges: Docker's network, and the tunnels from Cloudflare (cloudflared) and
# Pangolin (Newt), which arrive from a private address. The cms then keeps the
# X-Forwarded-Proto: https it needs to see the public origin, and rate limits see the real
# client IP. Cloudflare with its proxy (orange cloud) turned on but no tunnel connects from
# its own public addresses: set CMS_TRUSTED_PROXIES and CMS_CLIENT_IP_HEADERS in .env (the
# comments in .env.example say how). A list is space separated.
servers {
trusted_proxies static {$CMS_TRUSTED_PROXIES:private_ranges}
client_ip_headers {$CMS_CLIENT_IP_HEADERS:X-Forwarded-For}
}
}
{$CADDY_ADDRESS} {
# zstd or gzip for HTML, CSS, JS, JSON and SVG; bodies under 512 bytes are left alone.
encode zstd gzip
# Cache policy. Only the app knows whether a response is personal, so Caddy never
# overrides a Cache-Control the app sent; `?` sets a header only when it is missing:
# /_astro/* immutable for a year (the build's fingerprinted files; Astro sends it)
# /_cms/styles/* immutable on a hash match, 60 s otherwise (the cms)
# /media/* immutable for a year (the cms)
# pages and /api/* public for content pages, private for anything personal (the cms)
# no header at all revalidate every time (the fallback below)
@fingerprinted path /_astro/*
header @fingerprinted ?Cache-Control "public, max-age=31536000, immutable"
header ?Cache-Control "public, max-age=0, must-revalidate"
reverse_proxy app:4321
}You rarely need to edit it. The first block lists the proxies Caddy believes when they say who the visitor is, which matters in Behind a proxy. The second block compresses responses, adds cache headers where the site did not set any, and passes everything to the site.
Settings that differ from one server to the next, and secrets, live in a file called .env beside compose.yaml. It is not in git, so you make it on the server.
This command writes it and makes the secret in the same step. First change example.com in the second line to your domain, then paste the whole block:
cat > .env <<EOF
CMS_AUTH_SECRET=$(openssl rand -hex 32)
CMS_SITE_URL=https://example.com
COMPOSE_PROFILES=https
EOF
chmod 600 .envThe file now looks like this, except that your secret is different:
CMS_AUTH_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
CMS_SITE_URL=https://example.com
COMPOSE_PROFILES=httpsCMS_AUTH_SECRET is a long random string that the site uses to sign sign-in sessions and preview links. It must be at least 32 characters; openssl rand -hex 32 prints 64. Keep a copy somewhere safe. If you change it, everyone is signed out.CMS_SITE_URL is the address visitors use, exactly: https://, your domain, no slash at the end and no path. The site uses it for cookies, links in emails and sign-in checks, and in https mode Caddy gets the certificate for it.COMPOSE_PROFILES=https picks the Caddy that gets its own certificate.Only the site reads this file. Caddy and the other services get only the settings they need. The image never contains it. .env.example lists every setting with a comment, and the reference at the end of this chapter explains them.
-d runs it in the background.docker compose up -d --buildThe first time, Docker downloads Node and Caddy, installs the site's packages and builds the site. That takes a few minutes, and longer on a small server, and it prints a lot. Later builds reuse what Docker has cached. When it finishes you see lines such as Container my-site-app-1 Healthy and Container my-site-caddy-https-1 Started.
Caddy waits for the site to report that it is healthy before it starts. That takes up to half a minute: the health check is the first request the site gets, and the site applies its database migrations then.
docker compose psapp should say (healthy) and caddy-https should say Up.
Caddy asks for the certificate as soon as it starts. Follow its log:
docker compose logs -f caddy-httpsEach line is a small JSON record. Look for enabling automatic TLS certificate management with your domain in it, then obtaining certificate, then certificate obtained successfully. That last one means you have a certificate. Press Ctrl+C to stop following the log; the site keeps running.
If you see errors instead, or nothing happens, go to Troubleshooting. The most common causes are a DNS record that does not point at the server yet and a closed port 80.
From your computer, check both addresses. The first should answer HTTP/2 200, and the second should answer with a redirect to https:
curl -I https://example.com
curl -I http://example.comOpen https://example.com in a browser. You see a padlock, and the page says “Page not found”. That is correct for now: the data volume starts empty, so the site has no pages yet.
A new site has no pages and no accounts. docker compose run --rm app starts a one-off copy of the site's container, runs the command you give it and throws the container away. It shares the data volume with the running site, so what it does shows up at once.
docker compose run --rm app pnpm cms seed --starterdocker compose run --rm app pnpm cms user:create-admin --email you@example.com --password 'choose-a-long-password'The command line, password included, stays in the server's shell history, so use a password you do not use anywhere else.
"ok":true.curl -s https://example.com/api/healthhttps://example.com/admin and sign in.If you have already built pages on your computer and want to publish them as they are, the data/ folder in your site holds the database and the uploads, and it works as a backup. This replaces everything in the server's data volume, so do it before you add anything there. Afterwards you sign in with the account you use on your computer, and you should not run the two commands above.
pnpm dev, pack the folder and copy it to the server.tar czf my-site-data.tar.gz -C data .
scp my-site-data.tar.gz you@203.0.113.10:my-site/docker compose stop app
docker compose run --rm --no-deps -T --entrypoint sh app -c 'find /data -mindepth 1 -delete && tar xzf - -C /data' < my-site-data.tar.gz
docker compose start appThe same two steps restore a backup. Backups and updates explains them.
Run these in the site folder on the server.
docker compose ps # what is running, and whether the site is healthy
docker compose logs -f app # the site's log; Ctrl+C stops following it
docker compose logs --tail 100 caddy-https
docker compose restart app # restart the site
docker compose stop # stop everything, keeping the data
docker compose start # start it again
docker compose up -d --build # rebuild and restart whatever changed
docker compose down # remove the containers, keeping the dataNever run docker compose down -v on a live site. The -v deletes the volumes, and with them your database and uploads.
When you change .env, run docker compose up -d. Compose notices and restarts the site with the new settings, which takes about twenty seconds. A change to the code or to a package needs --build as well. Backups and updates covers updating the cms itself.
The setup above answers only on the exact address in CMS_SITE_URL. To send www.example.com to it as well, add a www record to your DNS, pointing at the same server, and add this block to the end of the Caddyfile. It works in https mode only. Then run docker compose restart caddy-https.
www.example.com {
redir https://example.com{uri} permanent
}The Dockerfile builds your site into one image. It starts from node:24-slim, installs the packages, builds the site and keeps only what is needed to run it, and it runs the site as the node user rather than as root. The database and uploads live in /data, a volume, so rebuilding the image never touches them. Pending migrations are applied when the site starts, and a health check asks /api/health every 30 seconds.
The scheduled jobs, such as expiring unpaid orders and clearing old baskets, run inside the site's own process from its first request. The health check makes one. If you ever run a second copy of the site, set jobs: { enabled: false } in site.config.ts for it, or run cms jobs run from a cron job of your own.
These go in .env. .env.example in your site folder lists them with comments.
| Setting | What it does | Default |
|---|---|---|
CMS_AUTH_SECRET | Required. Signs sessions and preview links. At least 32 characters. | none |
CMS_SITE_URL | The public address of the site. With https:// the site marks its cookies Secure and trusts the X-Forwarded-* headers that Caddy passes on. In https mode Caddy gets the certificate for it. | http://127.0.0.1:4321 |
COMPOSE_PROFILES | Which Caddy and which tunnel connector run: https, proxy, proxy,cloudflare or proxy,pangolin. | proxy |
CMS_BIND | Proxy mode: the address that caddy-proxy publishes the site's port on. | 127.0.0.1 |
CMS_HTTP_PORT, CMS_HTTPS_PORT | Https mode: the ports on the server that Caddy publishes. Change them only if 80 and 443 are taken. | 80, 443 |
CMS_TRUSTED_PROXIES | The proxies Caddy believes when they say who the visitor is and whether they used https, as addresses or ranges separated by spaces. | private_ranges |
CMS_CLIENT_IP_HEADERS | The header that carries the visitor's address. | X-Forwarded-For |
CLOUDFLARE_TUNNEL_TOKEN | The token of a Cloudflare Tunnel (Behind a proxy). | none |
PANGOLIN_ENDPOINT, NEWT_ID, NEWT_SECRET | The address and the credentials of a Pangolin site (Behind a proxy). | none |
PORT | The port the site listens on inside its container. The create command writes the number you chose into the Dockerfile, compose.yaml and the Caddyfile, so change all three together. | 4321 |
CMS_DATA_DIR | Where the SQLite file and the uploads live. The image sets /data, which is the data volume. | ./data |
CMS_AUTO_MIGRATE | Apply pending migrations when the site starts. Set it to false to run cms migrate yourself. | true |
CMS_NOINDEX | Set to true on a staging or test copy so search engines do not list it. Every page then asks crawlers to stay away, robots.txt turns them all back and the sitemap is no longer advertised. Leave it unset on the live site. | off |
CMS_EMAIL_FROM | The address to send email from until one is saved under Settings → Email. Without it, mail comes from no-reply@ and your site's host name. | none |
SMTP_URL, SMTP_USER, SMTP_PASSWORD | Sending email with SMTP (Going live). | none |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_REGION | Sending email with Amazon SES (Going live). | none |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | Taking payments with Stripe (Going live). | none |
CMS_ALLOW_FAKE_PAYMENTS | Lets the pretend payment page run on an https address. Leave it off on a live shop. | off |
DATABASE_URL | The connection string, when the site uses Postgres (Going live). | none |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | The OAuth client, when google() is listed under auth.providers (Accounts and sign-in). | none |
OIDC_<ID>_ISSUER, OIDC_<ID>_CLIENT_ID, OIDC_<ID>_CLIENT_SECRET | The address and the client of an OpenID provider. <ID> is the provider's id in capitals, so pocketId() reads OIDC_POCKET_ID_ISSUER and the two after it. | none |
CMS_AUTH_LOCAL_STAFF | Set to 1 to let staff sign in with passwords again while an OpenID provider is down and staffOnly is on. | off |
CMS_SCREENSHOT_WS | The address of the browser service that gives an AI assistant screenshots of your pages, when mcp.screenshots is set (AI assistants (MCP)). | none |
CMS_INSTAGRAM_APP_ID, CMS_INSTAGRAM_APP_SECRET | The Meta app, when the Instagram module is listed (Instagram feed). | none |
Before real visitors arrive, work through Going live: email, payments and a checklist. Then set up backups.