Build

Sites

Host a website or a front end next to your backend. Deploy any static build and it's live at name.atberth.com with HTTPS, or on your own domain. Every deploy gets its own link, only changed files are uploaded, and rolling back takes one click.

Deploy a site

Point berth sites deploy at your build folder. The site is created on the first deploy. The CLI fingerprints every file and uploads only the ones Berth doesn't have yet, so a redeploy of a big site takes seconds.

mkdir -p site && printf '<!doctype html><title>Hi</title><h1>Hello from Berth</h1>' > site/index.html
SITE=$(echo "$APP" | tr _ -)
berth sites deploy ./site --app "$APP" --site "$SITE" -m "first deploy"

It's live at once. The first visit to a new address takes a few seconds while its HTTPS certificate is issued:

curl -sS "https://$SITE.atberth.com/"

In the console, open your app, choose Sites, and drag your build folder onto the page.

Frameworks

Build locally or in CI, then deploy the output folder.

FrameworkBuildDeploy this folder
Vite (React, Vue, Svelte, Solid)npm run builddist
Astronpm run builddist
Next.jsoutput: "export" in next.config, then next buildout
SvelteKit@sveltejs/adapter-static, then npm run buildbuild
Nuxtnpx nuxi generate.output/public
Create React Appnpm run buildbuild
Gatsbygatsby buildpublic
Hugo, Eleventy, Jekyllhugo, npx @11ty/eleventy, jekyll buildpublic, _site, _site
Plain HTMLnonethe folder with index.html

For a single-page app (React Router, Vue Router), turn on spa in berth.json so every route serves index.html.

Previews and rollbacks

Every deploy has its own address, name--xxxxxxxx.atberth.com. A preview deploy gets that link without touching the live site. Share it, then publish it when it looks right.

printf '<h1>New design</h1>' > site/index.html
berth sites deploy ./site --app "$APP" --site "$SITE" --preview -m "new design"
berth sites deploys --app "$APP" "$SITE"

Make any deploy live, a preview or an older one, with berth sites promote SITE DEPLOY_ID. berth sites rollback SITE goes back to the previous live deploy. The switch is instant because nothing is re-uploaded. Preview links send X-Robots-Tag: noindex so search engines skip them.

Redirects, rewrites, headers

Put a berth.json at the top of your build folder. Everything is optional.

{
  "cleanUrls": true,
  "trailingSlash": false,
  "spa": false,
  "notFound": "/404.html",
  "redirects": [
    { "source": "/blog/:slug", "destination": "/posts/:slug", "status": 301 },
    { "source": "/docs/v1/*", "destination": "/docs/v2/:splat" }
  ],
  "rewrites": [
    { "source": "/api/*", "function": "api" },
    { "source": "/app/*", "destination": "/app/index.html" }
  ],
  "headers": [
    { "source": "/*", "headers": { "X-Frame-Options": "DENY" } }
  ]
}
KeyDefaultWhat it does
cleanUrlstrue/about serves about.html, and /about.html redirects to /about.
trailingSlashunsettrue adds a slash to page URLs, false removes it. Unset leaves URLs alone.
spafalsePaths with no matching file serve index.html. Missing assets like /app.js still 404.
notFound/404.htmlThe page shown, with status 404, when nothing matches.
redirectsChecked first. :name matches one segment, * matches the rest as :splat. Status 301 (default), 302, 303, 307, or 308. The query string is kept.
rewritesUsed when no file matches. Serve another file with destination, or run one of the app's functions with function.
headersAdded to every response whose path matches. Berth sets content type, caching, and ETag itself.

Moving from Netlify? _redirects and _headers files work as they are. Lines with status 200 in _redirects are rewrites.

Your API on the same domain

Rewrite a path to one of the app's functions, and your site and its API share one origin: no CORS, no second domain. With {"source": "/api/*", "function": "api"}, a request to /api/orders/7 runs the function api with the path /orders/7, the method, body, headers, and query. Deploy that function with --verify none for public endpoints, or --verify user to require a signed-in user's token.

Custom domains

Add your domain, then create one DNS record where you bought it. HTTPS certificates are issued on the first visit and renew by themselves.

berth sites domains add --app "$APP" my-site example.com --primary
berth sites domains add --app "$APP" my-site www.example.com --redirect-to example.com
DomainRecordValue
A bare domain, like example.comA named @, or ALIAS if your DNS host has itThe address that berth sites domains add and the console show, or my-site.atberth.com for ALIAS
A subdomain, like www.example.com or shop.example.comCNAME named www or shopmy-site.atberth.com

Then run berth sites domains verify my-site example.com, or press Check DNS in the console. The primary domain is the site's main address: my-site.atberth.com and your other domains redirect to it with the path kept.

Password protection

Protect the whole site, or only preview links, which is handy for staging and client reviews. Visitors enter the password once; any username works.

berth sites password --app "$APP" "$SITE" --set "client preview 1" --previews-only
berth sites password --app "$APP" "$SITE" --clear

Caching and speed

  • HTML is revalidated on every visit, so a new deploy shows up at once.
  • Files with a content hash in the name (app.3f9a8c1d.js) and everything under assets/, _next/static/, and _astro/ are cached for a year.
  • Every file has an ETag, so repeat visits get small 304 answers.
  • Responses are compressed with zstd or gzip, and byte ranges work for audio and video.
  • HTTP always redirects to HTTPS, and HSTS is on.

Stats

Berth counts visitors, requests, bandwidth, 404s, top pages, and top referrers. It sets no cookies and stores no IP addresses: a visitor is counted once per day through a salted hash, and the salt is thrown away every day.

berth sites stats --app "$APP" "$SITE" --days 7

Deploy from GitHub Actions

Create an account key with berth account keys create ci, save it as the repository secret BERTH_ACCOUNT_KEY, and add this workflow.

name: Deploy
on: { push: { branches: [main] }, pull_request: {} }
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci && npm run build
      - run: curl -fsSL https://atberth.com/install.sh | sh
      - run: |
          PREVIEW=$([ "${{ github.event_name }}" = pull_request ] && echo --preview)
          ~/.local/bin/berth sites deploy ./dist --app my-app --site my-site $PREVIEW -m "${{ github.sha }}"
        env:
          BERTH_ACCOUNT_KEY: ${{ secrets.BERTH_ACCOUNT_KEY }}

Pushes to main go live. Pull requests get a preview link in the job log.

Deploy with the HTTP API

Send a .tar.gz, .tar, or .zip of the build folder with the app's secret key. Add ?preview=true for a preview.

tar -czf site.tar.gz -C site .
curl -sS -X POST "https://api.atberth.com/v1/apps/$APP/sites/$SITE/deploys?message=from%20curl" \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/gzip" \
  --data-binary @site.tar.gz | jq '{url, files: .deploy.files}'

To upload only what changed, POST the file list as JSON, {"files": {"index.html": "<sha256>"}}. The answer lists the missing hashes: PUT each one to /sites/{site}/files/{sha256}, then POST /sites/{site}/deploys/{id}/finish. Every route is in the API reference.

Limits

FreePro
Sites320
Size of one deploy100 MB1 GB
Custom domains, HTTPS, previews, statsincludedincluded
Files per deploy20,00020,000
Largest file50 MB50 MB
Deploys kept for rollbacklast 20last 20

Sites serve static files and proxy to your functions. Server-side rendering, like Next.js without static export, isn't supported: export statically, or put the dynamic parts in functions.