Publishing - Spacefast Docs
Publishing
What one sf publish actually does, which files it sends, how change-only uploads work, and what the receipt tells you.
After this page you can publish a folder from the CLI, the dashboard, or the API, predict which files make it and which get dropped, and read a receipt well enough to know whether your site is live.
The mental model
One publish is four steps.
- Declare. The client lists every file it wants the version to hold, with a size and usually a
sha256. - Upload. The server answers with upload targets for the bytes it does not already have. Everything else is reused.
- Finalize. The version compiles into its served form and becomes
ready. - Promote. The
livepointer moves to it.
sf publish runs all four and prints a URL. POST /v1/publish collapses all four into one request and returns a receipt. The steps are separately addressable when you need them, which is how a resumed upload and a promote-later flow work.
Publish
CLIDashboardAPI
sf publish ./dist
With no argument it publishes the current directory, building it first if it detects a project. --message records a changelog entry on the version. --target preview creates a ready version without moving live.
sf publish ./dist --message "Fix pricing table"
sf publish --target preview --json
Drop takes a folder, a .zip, or a single index.html. Drag it onto the window, or use Choose a folder or Choose files. Pick a Team, a Destination of New space or Existing space, and for an existing Space either Add & update or Replace all. Progress runs Prepare → Upload → Build → Go live.
For a Space you already have open, the topbar Publish button asks How do you want to publish? and offers Publish from Git, Drop files, Use the Spacefast CLI, or an agent.
curl -X POST https://api.spacefast.com/v1/publish \
-H "Authorization: Bearer sfa_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/zip" \
--data-binary @site.zip
createPublish also takes a JSON manifest, multipart form data, a single HTML document, or raw bytes for one file.
What gets uploaded
Publishing walks the directory and builds a manifest. Three things can happen to a path.
Dropped without complaint
Some paths are never worth failing a whole publish over, so they are dropped and counted instead. Exact names at any depth:
.DS_Store .gitignore .npmrc .netrc
.env .env.local .env.production .env.development .env.test
_routes.json .spacefast/state.json .stattic/state.json
Whole directories at any depth: .git, .hg, .svn, .ssh, .aws, .cache, .turbo, .vercel, .netlify, .wrangler, node_modules, and .next/cache.
Anything that looks like a credential is dropped the same way: names starting .env., SSH private keys (id_rsa, id_ed25519, and friends), files named like a private key, and any .pem, .key, .p12, or .pfx.
Symlinks are skipped, not followed. The CLI prints Warning: skipped symlink <path> (symlinks are not published). for each one.
_headers and _redirects are convention files and always publish.
Rejected with an error
These fail the publish rather than getting dropped, because silently ignoring them would change what your site does.
| Path | Error code |
|---|---|
Untrimmed, starting /, containing \ or //, or not canonical |
invalid_file_path |
A segment ending in . or a space |
invalid_file_path |
| Canonical path over 1,024 bytes | path_too_long |
Named .htaccess or .user.ini |
static_control_file_not_supported |
Under a runtime control prefix, such as a top-level __sf or __spacefast |
static_runtime_control_path_not_supported |
| Two entries that canonicalize to the same path | manifest_duplicate_path |
Two artifacts claiming the same request route also stop the publish, so a lookup-order coin flip never decides which one visitors get.
Size and count
| Limit | Value | Error |
|---|---|---|
| Files per version | 100,000, 1,000 for an anonymous publish | manifest_too_many_files |
| Largest single file | 1 GiB, 50 MiB for an anonymous publish | version_file_too_large |
| Total bytes per version | 2 GiB | version_total_bytes_exceeded |
| Manifest request body | 16 MiB | manifest_body_too_large |
| Inline archive body | 128 MiB | publish_archive_too_large |
The file count is a platform ceiling: every claimed plan gets the same 100,000. Anonymous publishes run a tighter table.
Only changed bytes travel
Every declared file carries a sha256. The server compares your declaration against the version you are replacing, keeps the bytes it already has, and asks for the rest. The upload instructions come with a summary counting exactly that:
{ "upload": 3, "reused": 41, "ignored": 0 }
Deleting a file means leaving it out of the declaration. There is no delete list. That has one consequence worth knowing:
| Mode | What your file list means |
|---|---|
snapshot |
The complete site. Paths you leave out are removed |
additive |
Additions and replacements. Paths you leave out stay |
sf publish defaults to snapshot. POST /v1/publish defaults to additive. Set it with --publish-mode or the publishMode field.
Nothing changed
If your declaration reproduces the live version byte for byte, no version is created. The response comes back with a null version id and one info diagnostic:
oop_publish
That diagnostic is the only signal. There is no separate boolean.
One catch. A file declared without a sha256 cannot prove it is unchanged, so a manifest missing any hash always materializes a new version.
Waiting, or not
A publish holds one HTTP request open while the work runs, up to a 240 second ceiling. Past that the receipt degrades to next.action of poll and the work carries on in the background. You did not lose the publish.
Add ?async=1 to skip the wait and get an operation handle immediately. Only async=1 and async=true do that. async=0, async=false, and leaving it off all wait.
Reading the receipt
The parts you actually read:
| Field | What it tells you |
|---|---|
space.id, space.slug |
Which Space this landed in |
space.liveUrl |
The URL to open |
version.id, version.ref, version.number |
The version this publish created |
version.status |
ready means finalized |
version.immutableUrl |
The permanent per-version URL, null while finalizing |
activation.outcome |
activated, unpromoted, superseded, or pending |
next.action |
done, upload, finalize, or poll |
next.url |
Where to go for that action |
diagnostics |
Warnings, dropped paths, and noop_publish |
operation |
The handle to poll when finalize kept running |
next.action is the field to branch on. done means there is nothing left to do, and the hint says why, for example Live. Nothing left to do. An unpromoted outcome with done means the version is ready and you need to promote it yourself.
What the API accepts
POST /v1/publish sniffs the content type.
| Content type | Body |
|---|---|
application/json |
A file manifest plus metadata |
application/zip, application/x-tar, application/gzip |
A project archive |
multipart/form-data |
One binary part per file, or one archive part, plus an optional payload JSON part |
text/html |
A single document, published as index.html |
application/octet-stream |
Raw bytes for one file, path from the x-spacefast-path header |
Static zip or a build
An archive takes one of two paths, decided from the file names alone without unpacking it.
A zip publishes directly as static files when it contains an index.html and no build markers. A build marker is a lockfile, a workspace root file, a framework config file, or a vercel.json, netlify.toml, or wrangler.toml. A package.json next to your index.html is enough to make it a build.
Everything else becomes a Build that installs and builds your project, then publishes the output. Tar and tar.gz are always builds, only zip has the static path.
When a publish fails
| Code | What happened | Fix |
|---|---|---|
manifest_too_many_files |
More files than your plan allows | Publish a built output directory, not a source tree |
version_file_too_large |
One file over the per-plan ceiling | Host the asset elsewhere, or move up a plan |
version_total_bytes_exceeded |
The version is over 2 GiB | Split the site, or drop unused assets |
manifest_duplicate_path |
Two entries canonicalize to one path | Two files differ only by encoding or case folding. Rename one |
invalid_file_path |
A path Spacefast cannot canonicalize | Remove backslashes, doubled slashes, and trailing dots or spaces |
static_control_file_not_supported |
An .htaccess or .user.ini shipped |
Delete it. Use _redirects and _headers instead |
unsupported_publish_media_type |
The content type is not one of the five above | Send a zip or a JSON manifest |
publish_archive_compression_ratio_exceeded |
The archive expands over 100 times its size | Repack without the zip bomb shaped content |