Run it on a server

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.

What you need

  • A Linux server that you can log in to over SSH and use 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.
  • A domain name, and access to its DNS settings.
  • Your site, created as in Try it on your computer.

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.

Point your domain at the server

In your domain's DNS settings, add an A record that points your domain at the server's IPv4 address:

TypeNameValue
Aexample.com (some DNS panels want @)203.0.113.10
AAAA, only if the server has IPv6example.comthe 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.com

It 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.

Install Docker

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.

  1. Log in to the server.
ssh you@203.0.113.10
  1. Install Docker from Docker's own package source. These commands add Docker's signing key and package source to apt, then install Docker Engine and the Compose plugin, which gives you the docker compose command. They follow Docker's instructions for Ubuntu, which are worth a look in case they have changed.
Install Docker on Ubuntu
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-plugin
  1. Check that Docker works. This downloads a tiny test image and prints a greeting.
sudo docker run --rm hello-world
  1. Let your user run Docker without sudo. 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 $USER
  1. Log out and back in, so that the change takes effect.
exit
ssh you@203.0.113.10
  1. Check that Compose works without sudo. It prints a version number.
docker compose version

Docker starts when the server boots. Together with restart: unless-stopped in compose.yaml, that is what brings your site back after a restart.

Open the firewall

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 status

Port 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.

Put the site on the server

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.

  1. On your computer, in the site folder, make the first commit. The create command does not start a repository for you.
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.

  1. Create an empty private repository on GitHub, GitLab or any git host. Copy its address, add it as the remote and push.
git remote add origin git@github.com:you/my-site.git
git push -u origin main
  1. On the server, make an SSH key, so that the server can read the private repository.
ssh-keygen -t ed25519 -C "my-site server" -f ~/.ssh/id_ed25519 -N ""
cat ~/.ssh/id_ed25519.pub
  1. Add the public key it printed to the repository as a read-only deploy key. On GitHub that is under the repository's Settings → Deploy keys. Other hosts have the same idea under another name.
  2. Clone the repository on the server and go into it.
git clone git@github.com:you/my-site.git
cd my-site

Without git

Copy 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 files that run the 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:

compose.yaml
# 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.

Caddyfile
# 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.

Create the settings file

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 .env

The file now looks like this, except that your secret is different:

.env
CMS_AUTH_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
CMS_SITE_URL=https://example.com
COMPOSE_PROFILES=https
  • CMS_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.

Start the site

  1. Build and start everything. -d runs it in the background.
docker compose up -d --build

The 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.

  1. Check that both containers are up.
docker compose ps

app should say (healthy) and caddy-https should say Up.

Watch Caddy get the certificate

Caddy asks for the certificate as soon as it starts. Follow its log:

docker compose logs -f caddy-https

Each 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.com

Open 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.

Create your admin account and content

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.

  1. Load the starter content: a home page, an about and a privacy page, the sign-in pages, menus and these docs. It is safe to run again, because it skips what already exists.
docker compose run --rm app pnpm cms seed --starter
  1. Make your admin account. Use your own email address and a long password. Put the password in single quotes.
docker 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.

  1. Check that the site reports itself healthy. You should see "ok":true.
curl -s https://example.com/api/health
  1. Open https://example.com/admin and sign in.

Bring the content from your computer instead

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.

  1. On your computer, stop 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/
  1. On the server, in the site folder, stop the site, load the file into the data volume and start the site again.
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 app

The same two steps restore a backup. Backups and updates explains them.

Day-to-day commands

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 data

Never 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.

Also answer on www

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.

Caddyfile
www.example.com {
	redir https://example.com{uri} permanent
}

What is in the image

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.

Settings reference

These go in .env. .env.example in your site folder lists them with comments.

SettingWhat it doesDefault
CMS_AUTH_SECRETRequired. Signs sessions and preview links. At least 32 characters.none
CMS_SITE_URLThe 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_PROFILESWhich Caddy and which tunnel connector run: https, proxy, proxy,cloudflare or proxy,pangolin.proxy
CMS_BINDProxy mode: the address that caddy-proxy publishes the site's port on.127.0.0.1
CMS_HTTP_PORT, CMS_HTTPS_PORTHttps mode: the ports on the server that Caddy publishes. Change them only if 80 and 443 are taken.80, 443
CMS_TRUSTED_PROXIESThe 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_HEADERSThe header that carries the visitor's address.X-Forwarded-For
CLOUDFLARE_TUNNEL_TOKENThe token of a Cloudflare Tunnel (Behind a proxy).none
PANGOLIN_ENDPOINT, NEWT_ID, NEWT_SECRETThe address and the credentials of a Pangolin site (Behind a proxy).none
PORTThe 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_DIRWhere the SQLite file and the uploads live. The image sets /data, which is the data volume../data
CMS_AUTO_MIGRATEApply pending migrations when the site starts. Set it to false to run cms migrate yourself.true
CMS_NOINDEXSet 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_FROMThe 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_PASSWORDSending email with SMTP (Going live).none
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_REGIONSending email with Amazon SES (Going live).none
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRETTaking payments with Stripe (Going live).none
CMS_ALLOW_FAKE_PAYMENTSLets the pretend payment page run on an https address. Leave it off on a live shop.off
DATABASE_URLThe connection string, when the site uses Postgres (Going live).none
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETThe OAuth client, when google() is listed under auth.providers (Accounts and sign-in).none
OIDC_<ID>_ISSUER, OIDC_<ID>_CLIENT_ID, OIDC_<ID>_CLIENT_SECRETThe 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_STAFFSet to 1 to let staff sign in with passwords again while an OpenID provider is down and staffOnly is on.off
CMS_SCREENSHOT_WSThe 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_SECRETThe Meta app, when the Instagram module is listed (Instagram feed).none

Next

Before real visitors arrive, work through Going live: email, payments and a checklist. Then set up backups.