Skip to Content
Use EnergonPublish a complete site update

Replace every file in a site at its existing URL in one commit, so readers never see new HTML next to old JavaScript or CSS. Use a deployment session because separate path writes publish one at a time and can expose a half-updated release. You create a session, upload the files it declares, prepare until ready, and then explicitly commit. Until that commit, the published site stays exactly as it was.

Publish a complete site update

Atomic site deployments arrived in Energon v1.10.0 . Check GET /v1/help on your Energon: if limits.zip_import_bytes is absent, this host runs an older release and imports keep the older 25 MB ZIP cap.

What the commit guarantees

A deployment session stages a complete candidate under fresh storage keys. Commit verifies that the candidate is complete, checks that the site has not changed since the session began, and then moves the site’s published version pointer in one database operation. The site keeps its URL, share password, write password, expiry, and write policy.

  • Complete or nothing. Before commit, staged files are invisible to readers, including files that have finished uploading. A failed, aborted, or expired session never partially replaces the live site.
  • Replacement, not merge. The new file set replaces the old one. Any path you leave out of the session disappears at commit. An empty manifest publishes an empty site.
  • Per-request selection. Each request to the site selects whichever version is published at that moment. A page that a browser already loaded can request its stylesheet or script after the commit and receive the new version of that asset. Refresh an open page after a deployment if it looks inconsistent. Energon does not pin a page and its assets to one version.

Upload and preparation are not publication. A 201 file receipt or a 200 ready preparation response means the candidate is staged. Only a successful commit publishes it.

There is no revision history, user rollback, version-specific public URL, or page pinning. X-Energon-Site-Version on a site response reports which immutable version served that one request; it is not a request header for choosing a version. To return to an earlier state, publish those files again as a new deployment.

Before you begin

You need an API token that can write the site, an existing site id, and the complete set of files you want published. Read the live limits first:

export ENERGON_ORIGIN="https://hub.your-company.example" test -n "$ENERGON_TOKEN" || { echo "Set a human-authorized ENERGON_TOKEN" >&2; exit 1; } AUTH_HEADER="Authorization: Bearer $ENERGON_TOKEN" curl -fsS "$ENERGON_ORIGIN/v1/help" | jq .limits

A new deployment accepts at most 200 files. Each file must fit limits.file_bytes (100 MiB by default) and the hosting plan’s request-body limit. A ZIP must fit limits.zip_import_bytes (100 MiB by default) and expand to no more than limits.zip_extracted_bytes (500 MiB by default). Staged files count toward the instance storage quota while the session is open, and the old version stays charged until cleanup, so leave room for both. Configure Energon policy explains the defaults and the operator settings that change them.

1. Record the base generation

Every site has a numeric content_generation that changes whenever its published content changes. The session must name that generation as expected_version. Commit refuses to publish if the site moved on in the meantime.

SITE_ID="abc123" SITE_JSON=$(curl -fsS "$ENERGON_ORIGIN/v1/sites/$SITE_ID" -H "$AUTH_HEADER") GENERATION=$(printf '%s' "$SITE_JSON" | jq .content_generation) printf '%s' "$SITE_JSON" | jq '{content_generation, version_id, url}'

2. Build the manifest and a persisted idempotency key

List every path you want published, with its exact byte size, SHA-256, and content type. Hash the local bytes you will upload, not a different build.

cd dist MANIFEST=$(find . -type f | sed 's|^\./||' | sort | while read -r path; do size=$(wc -c < "$path" | tr -d ' ') hash=$(shasum -a 256 "$path" | cut -d ' ' -f 1) case "$path" in *.html) type="text/html" ;; *.css) type="text/css" ;; *.js) type="text/javascript" ;; *) type="application/octet-stream" ;; esac jq -n --arg p "$path" --argjson s "$size" --arg h "$hash" --arg t "$type" \ '{path:$p,size:$s,sha256:$h,content_type:$t}' done | jq -s .)

The idempotency key is Unix milliseconds, a dot, and a UUIDv4. Its timestamp must be within the last hour when the session is created. Save the key with the exact manifest before sending the request, so a retry after a lost response reuses both:

KEY="$(node -e 'console.log(Date.now()+"."+crypto.randomUUID())')" jq -n --argjson v "$GENERATION" --arg k "$KEY" --argjson files "$MANIFEST" \ '{expected_version:$v,idempotency_key:$k,files:$files}' > ../deployment-intent.json

3. Create the session

SESSION_JSON=$(curl -fsS "$ENERGON_ORIGIN/v1/sites/$SITE_ID/deployments" \ -H "$AUTH_HEADER" \ -H "Content-Type: application/json" \ --data-binary @../deployment-intent.json) DEPLOYMENT_ID=$(printf '%s' "$SESSION_JSON" | jq -r .deployment_id) STATUS_URL=$(printf '%s' "$SESSION_JSON" | jq -r .status_url)

A new session returns 201. Sending the identical body with the same key returns 200 and the same session. The same key with a different manifest or base generation returns 409 idempotency_conflict; do not reuse a key for a new intent.

4. Upload every declared file

PUT each file’s raw bytes to the session with its declared content type. Upload from a file path so the client sends Content-Length.

printf '%s' "$MANIFEST" | jq -r '.[] | [.path, .content_type] | @tsv' | while IFS=$'\t' read -r path type; do curl -fsS -X PUT "$STATUS_URL/files/$path" \ -H "$AUTH_HEADER" \ -H "Content-Type: $type" \ --data-binary "@$path" done

A first upload returns 201; repeating the same bytes returns 200. Bytes that do not match the declared size or SHA-256 are rejected, and an undeclared path is refused. Percent-encode path segments that contain spaces or reserved characters. Uploads resume between files, not within one file: if a large PUT fails partway, send that whole file again.

5. Prepare until ready

Preparation validates the staged candidate. It never publishes.

while :; do CODE=$(curl -sS -X POST "$STATUS_URL/prepare" -H "$AUTH_HEADER" \ -o prepare.json -w '%{http_code}') jq '{state, progress, next_action, error, message}' prepare.json test "$CODE" = 200 && break test "$CODE" = 202 || exit 1 sleep 2 done

202 means more preparation work remains; repeat the request. 200 with state: "ready" means the candidate is complete. A manifest deployment is usually ready on the first call. A ZIP may need several calls because each one expands a bounded part of the archive.

6. Commit explicitly

COMMIT_JSON=$(curl -fsS -X POST "$STATUS_URL/commit" -H "$AUTH_HEADER") printf '%s' "$COMMIT_JSON" | jq '{state, version_id, url, completed_at, result_expires_at}' SITE_URL=$(printf '%s' "$COMMIT_JSON" | jq -r .url) curl -fsS -D - -o /dev/null "$SITE_URL" | grep -i '^x-energon-site-version'

Prepare and commit take no request body. A successful commit returns 200 with state: "committed", the new version_id, and the site’s unchanged url. Repeating commit on a committed session returns the same receipt rather than publishing again.

If another deployment, path write, or import changed the site after your session began, commit returns 409 deployment_conflict and the live site keeps the other change. Read the site again, decide whether your replacement is still wanted, and create a new session with the new generation and a new key.

Recover from interruptions

Keep STATUS_URL and the idempotency key until you have a committed receipt. A lost response is not a reason to start over.

curl -fsS "$STATUS_URL" -H "$AUTH_HEADER" | jq '{state, expires_at, progress, next_action, version_id, url, result_expires_at}'

The status read reports state (uploading, ready, committing, committed, aborted, expired, or failed), progress.stored_files, progress.missing_paths, and next_action (upload, prepare, commit, or none). Follow next_action: upload the missing paths or the declared ZIP, prepare again, or repeat the commit.

  • Session lifetime. A session must reach commit within one hour of creation (expires_at). After that it is expired, its staged files are queued for cleanup, and the live site is unchanged. Create a new session.
  • Receipt lifetime. A committed receipt stays readable for seven days (result_expires_at). During that window a repeated commit or status read recovers the same version_id and url. After it, the status read is 410.
  • Abort. DELETE "$STATUS_URL" returns 204 and discards an unpublished session. A session that already committed cannot be aborted; publish a new deployment instead.
StatuserrorWhat to do
400invalid_intentFix the manifest, key format, or base generation.
400checksum_mismatch / bad_requestThe uploaded bytes do not match the declared size or hash. Upload the right bytes.
403deployment_forbiddenThe session belongs to another account. Use the account that created it.
409deployment_conflictThe site changed, or another preparation holds the session. Read status; if the site changed, start a new intent.
409deployment_incompleteA declared input is missing. Upload it, then prepare again.
409idempotency_conflictThe key already names a different intent. Use a new key for a new intent.
409site_busyAnother write is in progress, or an unconverted older site is still being converted. Retry after reading the site again.
410deployment_expired / deployment_missing / idempotency_expiredThe session, receipt, or key window is gone. Read the site and create a new session.
413too_large / storage_capOver a file or ZIP limit, or the instance is out of storage. Shrink the input or ask an operator to free space.

Deploy from a ZIP

Instead of files, declare the archive’s size and SHA-256. Optional max_extracted_bytes and max_files ceilings must fit this Energon’s limits.

ZIP_SIZE=$(wc -c < site.zip | tr -d ' ') ZIP_HASH=$(shasum -a 256 site.zip | cut -d ' ' -f 1) jq -n --argjson v "$GENERATION" --arg k "$KEY" --argjson s "$ZIP_SIZE" --arg h "$ZIP_HASH" \ '{expected_version:$v,idempotency_key:$k,archive:{size:$s,sha256:$h,max_files:200}}' > zip-intent.json STATUS_URL=$(curl -fsS "$ENERGON_ORIGIN/v1/sites/$SITE_ID/deployments" \ -H "$AUTH_HEADER" -H "Content-Type: application/json" \ --data-binary @zip-intent.json | jq -r .status_url) curl -fsS -X PUT "$STATUS_URL/archive" \ -H "$AUTH_HEADER" -H "Content-Type: application/zip" \ --data-binary @site.zip

Then prepare until ready and commit, as above. One common wrapping directory is stripped. Unsafe paths, duplicate normalized paths, encrypted or unsupported entries, and checksum mismatches are rejected before anything is published. A ZIP deployment also replaces the whole site.

POST /v1/sites/{id}/import is different: it merges the archive into the existing site and keeps paths the archive does not contain. See Manage shared work.

Hand the session to a machine without a token

A CI runner or sandbox that built the site can publish it without holding an API token. The token holder approves one exact session, then mints a whole-site grant bound to it. The machine uploads, prepares, and commits through the content origin with only the grant secret.

  1. The building machine finishes its build first and sends the token holder its manifest, or the ZIP’s size and SHA-256. A grant approves those exact bytes, not a future build.

  2. The token holder creates the session as in steps 1 to 3, then mints the grant:

    GRANT_JSON=$(curl -fsS "$ENERGON_ORIGIN/v1/grants" \ -H "$AUTH_HEADER" -H "Content-Type: application/json" \ --data "{\"target\":{\"type\":\"site_deployment\",\"deployment_id\":\"$DEPLOYMENT_ID\"},\"expires_in\":\"15m\"}") printf '%s' "$GRANT_JSON" | jq '{id, expires_at, status_url, upload_url, archive_url, prepare_url, commit_url}'
  3. Send the machine the once-shown secret and the returned URLs over a private channel. The machine sends Authorization: Bearer <secret> to those content-origin /_deployment-grants/{id} URLs only. It replaces {path} in upload_url for each declared file, or PUTs the ZIP to archive_url, then POSTs prepare_url until ready and POSTs commit_url.

curl -fsS -X PUT "${UPLOAD_URL/\{path\}/index.html}" \ -H "Authorization: Bearer $GRANT_SECRET" \ -H "Content-Type: text/html" \ --data-binary @index.html curl -fsS -X POST "$PREPARE_URL" -H "Authorization: Bearer $GRANT_SECRET" curl -fsS -X POST "$COMMIT_URL" -H "Authorization: Bearer $GRANT_SECRET"

The grant works across all of those steps and is used up only by publication. It cannot change the approved manifest or archive, the base generation, the target site, or site settings such as passwords, expiry, and write policy. Settings headers on any grant request, and any body on prepare or commit, return 400. The grant’s expires_at is the earliest of expires_in (15m by default, up to 1h), the session’s one-hour deadline, and the minting token’s expiry.

After a lost commit response, the machine can GET status_url or repeat the commit with the same secret to recover the committed receipt, even after the grant itself has expired, for up to seven days. Revoking the minting token blocks every further use, including receipt recovery, with 410 grant_failed. The token holder can read the same session at its /v1 status URL until the receipt expires.

For handing over a single file, keep using the one-file grant in Let a machine upload without a token. That grant publishes immediately on its one PUT and does not use prepare or commit.

Common mistakes

Do not report a site as published after uploads or a ready preparation; only a committed receipt publishes it. Do not leave a path out of the manifest unless you mean to delete it. Do not mint a new idempotency key to retry a timed-out request: read the status URL and continue the same session.

Repeated single-path PUTs are not a cheaper way to update a folder. Each path write publishes a full new snapshot of the site that keeps the other paths, so ten PUTs create ten versions and use more temporary storage than one deployment. Readers can also see the intermediate states.

Keep grant secrets out of URLs, logs, commits, and published files. A grant authorizes only its bound session; it is not a token and cannot call /v1.

For the exact routes and fields, see HTTP API. For storage headroom and plan requirements, see Configure Energon policy and Deploy an Energon host.

Last updated on