Deployment

On this page

Deployment

site/ is a plain static site - host it anywhere that serves static files. bxSites deploy ships it there directly, in one command: S3 (and any S3-compatible service - DigitalOcean Spaces, Cloudflare R2, Backblaze B2, MinIO), Azure Blob Storage, Google Cloud Storage, Firebase Hosting, FTP, SFTP, rsync-over-SSH, Netlify, Vercel, Cloudflare Pages, a local directory, or GitHub Pages.

The deploy command

Every target but the two simplest (local/github-pages, which work from flags alone - see CLI Reference) is configured via a deployments/<name>.json file at your project root - one file per deploy target you actually use, each naming which target it is plus that target's own fields:

bxSites deploy --entry=production
{ "target": "s3", "bucket": "my-docs-site", "accessKeyIdEnvVar": "AWS_ACCESS_KEY_ID", "secretAccessKeyEnvVar": "AWS_SECRET_ACCESS_KEY" }

Secrets always come from an environment variable, never from a literal value in deployments/*.json. Every field ending in EnvVar names the environment variable holding the real secret (an access key, a password, an API token) - resolved live at deploy time, so deployments/*.json itself is always safe to commit. A field that's a path to a credential file you already manage yourself (an SSH private key, a downloaded GCP service-account JSON key) is the one exception - a plain field, since the file itself is what's kept out of version control, not its path. Locally, those environment variables can come from a .env file too (BoxLang loads one automatically and getSystemSetting() - what every target uses to resolve them - checks it transparently) instead of exporting them into your shell by hand; in CI, set them as real secrets on the runner.

Deploying to every target at once

Run bxSites deploy with neither --entry nor --target and every deployments/*.json entry is deployed in turn, off a single shared build:

bxSites deploy

The site is only built once no matter how many entries you have. One target failing doesn't stop the rest - every entry is attempted, and the command only exits non-zero if at least one of them failed; the summary reports how many succeeded (e.g. Deployed to 2/3 target(s) (1 failed)). Add --verbose (works with --entry/--target too) to print a progress line as the build and each target start and finish, instead of just the final summary.

local

Copies the built site to any directory - a shared drive, a staging folder, anywhere. The only target that needs no deployments/ entry at all.

bxSites deploy --target=local --destination=/path/to/somewhere

github-pages

The same push gh-deploy does, reachable from this unified command too - also needs no deployments/ entry:

bxSites deploy --target=github-pages [--branch=gh-pages] [--remote=origin] [--message="..."]

s3

Real AWS S3, or any S3-compatible service - set endpoint for anything other than AWS itself, and forcePathStyle: true for most non-AWS providers.

{
  "target": "s3",
  "bucket": "my-docs-site",
  "region": "us-east-1",
  "prefix": "",
  "accessKeyIdEnvVar": "AWS_ACCESS_KEY_ID",
  "secretAccessKeyEnvVar": "AWS_SECRET_ACCESS_KEY"
}
{
  "target": "s3",
  "bucket": "my-docs-site",
  "endpoint": "https://nyc3.digitaloceanspaces.com",
  "forcePathStyle": true,
  "accessKeyIdEnvVar": "SPACES_KEY",
  "secretAccessKeyEnvVar": "SPACES_SECRET"
}

The same shape (custom endpoint + forcePathStyle: true) also covers Cloudflare R2 (https://<accountid>.r2.cloudflarestorage.com), Backblaze B2, and MinIO/Wasabi.

azure

Azure Blob Storage, authenticated with a SAS token, an account key, or a full connection string - exactly one of the three.

{
  "target": "azure",
  "account": "mystorageaccount",
  "container": "site",
  "accountKeyEnvVar": "AZURE_STORAGE_KEY"
}

gcs

Google Cloud Storage, authenticated with a downloaded service-account JSON key (Google Cloud Console -> IAM & Admin -> Service Accounts -> Keys).

{
  "target": "gcs",
  "bucket": "my-docs-site",
  "serviceAccountKeyPath": "/path/to/service-account.json"
}

firebase

Firebase Hosting, using the same kind of service-account key as gcs.

{
  "target": "firebase",
  "siteId": "my-firebase-site",
  "serviceAccountKeyPath": "/path/to/service-account.json"
}

ftp / sftp

Uploads the whole site to a remote server over FTP or SFTP, preserving its folder structure. SFTP accepts a password or an SSH key.

{
  "target": "sftp",
  "host": "example.com",
  "username": "deploy",
  "remotePath": "/var/www/html",
  "key": "/home/me/.ssh/id_rsa"
}

rsync

Syncs the site to a remote server over SSH via the real rsync binary - faster than FTP/SFTP for a full rebuild, since it only transfers what changed. Requires rsync and ssh on the machine running bxSites.

{
  "target": "rsync",
  "host": "example.com",
  "username": "deploy",
  "remotePath": "/var/www/html",
  "identityFile": "/home/me/.ssh/id_rsa"
}

netlify

{
  "target": "netlify",
  "siteId": "my-site-id-or-name.netlify.app",
  "authTokenEnvVar": "NETLIFY_AUTH_TOKEN"
}

vercel

{
  "target": "vercel",
  "projectId": "my-project",
  "authTokenEnvVar": "VERCEL_TOKEN"
}

cloudflare-pages

Cloudflare has no officially documented REST API for direct-upload deploys - only its wrangler CLI. This target reverse-engineers Wrangler's own upload flow, and needs a BLAKE3 hash implementation on the JVM classpath that most default Java installs don't ship - see CLI Reference and the target's own source for the full, honest detail on this one's rough edges.

{
  "target": "cloudflare-pages",
  "accountId": "your-account-id",
  "projectName": "my-project",
  "apiTokenEnvVar": "CLOUDFLARE_API_TOKEN"
}

The package command

Prefer a plain archive over any of the targets above - attaching a build to a GitHub release, handing it to a host that only accepts a zip upload, or shipping it somewhere none of the pluggable targets reach? bxSites package builds the site, then zips it into a single file whose root is the built site's own contents (not a wrapping site/ folder):

bxSites package
bxSites package --output=dist/my-site.zip

--output defaults to <projectRoot>/site.zip; a relative value is resolved against the project root, and its parent directories are created automatically if they don't already exist.

GitHub Actions (multi-version publishing)

For automatic publishing on every push, rather than a manually-run bxSites deploy/gh-deploy, this module ships a ready-to-use GitHub Actions workflow (.github/workflows/pages.yml) that publishes main and development as two independently-live versions of the same site to GitHub Pages. The rest of this guide covers that workflow, which this repo's own docs use.

What it does

On every push to main or development that touches docs/, bxsites.yaml, or the module's own source (theme/pipeline changes), the workflow:

  1. Installs BoxLang + bx-markdown
  2. Registers this repo as a module so boxlang bxSites build resolves
  3. On any branch but main, points baseURL at .../<branch-name>/ for just this build (see below)
  4. Runs boxlang bxSites build
  5. Pushes site/ to the gh-pages branch - main to the site root, development to /development/ - without touching the other version

It's also available as a manual trigger (workflow_dispatch) from the Actions tab, for a one-off republish without a new commit.

One-time setup

GitHub Pages needs to be pointed at the gh-pages branch before the workflow can publish anything - this is a repository setting, not something a workflow file can turn on by itself. The first successful run creates gh-pages for you, so do this after the workflow has run at least once:

  1. Settings -> Pages
  2. Under Build and deployment -> Source, choose Deploy from a branch
  3. Under Branch, choose gh-pages and / (root)

After that, every matching push builds and deploys automatically. The published URL shows up under Settings -> Pages once the first deploy completes.

Publishing two versions at once

main publishes to the site root (https://<user>.github.io/<repo>/) - treat this as the stable/released docs. development publishes to /development/ (https://<user>.github.io/<repo>/development/) - the latest, unreleased docs. Both stay live simultaneously: each branch's job only pushes to gh-pages with keep_files: true and its own destination_dir, so a development deploy never overwrites main's content and vice versa.

main's own bxsites.yaml should have baseURL set to the site root (https://<user>.github.io/<repo>/); the workflow overrides it for every other branch at build time, so development's bxsites.yaml doesn't need its own baseURL entry for this to work.

To add a third branch (e.g. a release/2.0 preview), add it to the on.push.branches list and give it its own if: github.ref_name == '...' deploy step with destination_dir: release-2.0 (or similar) - the pattern is the same as development's.

Using this for your own project

Copy .github/workflows/pages.yml into your own project (adjust the modules: line if your project needs anything beyond bx-markdown), enable Pages as above, and pushes to main/development will publish the same way. If you only want a single published version, delete the branch you don't need from on.push.branches and its matching deploy step.

Serving from a project Pages sub-path

A GitHub project Pages site (as opposed to a <user>.github.io user site) is served from https://<user>.github.io/<repo>/, not from the domain root. Set baseURL in bxsites.yaml to that full URL so every internal link, asset and nav entry gets the /<repo>/ prefix it needs - and so a real sitemap.xml gets generated too:

baseURL: "https://<user>.github.io/<repo>/"
{ "baseURL": "https://<user>.github.io/<repo>/" }

See Configuration for the full breakdown of what baseURL does. A <user>.github.io user site, or any custom domain mapped to the site root, can leave baseURL at its default (/).

Restricting who can reach your site

There's no built-in access control here - bx-sites only ever produces a plain static site/, and a static file has no concept of "who's asking." bxsites.json's robots: false tells well-behaved crawlers not to index a build (useful for a staging/preview deploy you don't want turning up in search results), but it's a polite request, not a lock - the URL still works for anyone who has it. If you actually need to gate access, that has to happen in front of the static files, at whichever host is serving them - a few common, static-friendly options:

  • Cloudflare Pages/Access - put the deployed site behind a Cloudflare Access policy (email allowlist, SSO, or a one-time PIN), no application code needed.
  • Netlify - built-in password protection per site or per deploy, from the site settings alone.
  • A tiny reverse-proxy (any host) - HTTP Basic Auth in front of the static files (an .htpasswd-style rule, or a one-file Cloudflare Worker/Netlify Edge Function) is enough for "keep search engines and randoms out," though it's not real per-user identity the way a signed-in app would have.

None of these are bx-sites features - they're host-level settings you turn on wherever site/ ends up being served.

Edit this page Download Markdown Last updated Aug 28, 2026, 3:16:38 AM