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.
| Framework | Build | Deploy this folder |
|---|---|---|
| Vite (React, Vue, Svelte, Solid) | npm run build | dist |
| Astro | npm run build | dist |
| Next.js | output: "export" in next.config, then next build | out |
| SvelteKit | @sveltejs/adapter-static, then npm run build | build |
| Nuxt | npx nuxi generate | .output/public |
| Create React App | npm run build | build |
| Gatsby | gatsby build | public |
| Hugo, Eleventy, Jekyll | hugo, npx @11ty/eleventy, jekyll build | public, _site, _site |
| Plain HTML | none | the 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" } }
]
}| Key | Default | What it does |
|---|---|---|
cleanUrls | true | /about serves about.html, and /about.html redirects to /about. |
trailingSlash | unset | true adds a slash to page URLs, false removes it. Unset leaves URLs alone. |
spa | false | Paths with no matching file serve index.html. Missing assets like /app.js still 404. |
notFound | /404.html | The page shown, with status 404, when nothing matches. |
redirects | Checked first. :name matches one segment, * matches the rest as :splat. Status 301 (default), 302, 303, 307, or 308. The query string is kept. | |
rewrites | Used when no file matches. Serve another file with destination, or run one of the app's functions with function. | |
headers | Added 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| Domain | Record | Value |
|---|---|---|
A bare domain, like example.com | A named @, or ALIAS if your DNS host has it | The 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.com | CNAME named www or shop | my-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" --clearCaching 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 underassets/,_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 7Deploy 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
| Free | Pro | |
|---|---|---|
| Sites | 3 | 20 |
| Size of one deploy | 100 MB | 1 GB |
| Custom domains, HTTPS, previews, stats | included | included |
| Files per deploy | 20,000 | 20,000 |
| Largest file | 50 MB | 50 MB |
| Deploys kept for rollback | last 20 | last 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.