Deploy a local folder to Cloudflare Pages via the Direct Upload API — no wrangler, no npm.
Project description
cf-publish
Deploy a local folder to Cloudflare Pages from Python — no wrangler,
no npm, no Node.js. One pip install, one command.
pip install cf-publish
export CLOUDFLARE_API_TOKEN=... # token with "Cloudflare Pages: Edit"
export CLOUDFLARE_ACCOUNT_ID=... # shown on the dashboard overview page
cf-publish ./public --project my-site
That's it. The contents of ./public become the site. The project is created
on first deploy if it doesn't exist.
What it's for: publish a static site you built with Python (a blog, a
company site, docs) to the world without Node.js. The Pages free tier is
plenty for personal and small-business sites, global CDN and HTTPS included.
For large file distribution, pair it with R2 (free egress) via
cf-publish r2 sync — a site that runs at $0/month, hosting and
bandwidth included.
日本語の説明は README.ja.md にあります。
Why
The only official way to do a
Direct Upload
deploy is wrangler, which drags in the whole Node.js toolchain. If your build
pipeline is Python (or just a folder of files), that's a lot of machinery for
one HTTP conversation. cf-publish implements the same upload protocol in
~300 lines of Python with two dependencies (httpx, blake3).
- Content-addressed uploads — files are hashed the same way wrangler hashes them, so unchanged files are never re-uploaded (fast repeat deploys, and the cache is shared with wrangler).
- Concurrent uploads with retry and exponential backoff on 429/5xx.
- Pre-flight validation of the Pages limits (25 MiB/file, 20,000 files/deployment) before anything is sent.
- Root-level
_headers/_redirectsare attached to the deployment the way wrangler does it, so Pages actually parses and applies the rules (uploading them as plain assets would serve them as static files instead).
Usage
cf-publish DIRECTORY --project NAME [options]
--branch BRANCH 'main' deploys to production, anything else gets a
preview URL (default: main)
--no-create fail if the project doesn't exist instead of creating it
--exclude PATTERN fnmatch pattern to skip, matched against the relative
path and the filename; repeatable (e.g. --exclude '*.map')
--dry-run show what would be uploaded, deploy nothing
--quiet print only the deployment URL
--json print a JSON result (url, files, unique, uploaded,
duration, dry_run)
Progress goes to stderr, results to stdout, so both --quiet and --json
compose cleanly with shell pipelines and CI.
Credentials
Environment variables win; otherwise ~/.config/cloudflare/pages.env is read
(plain KEY=VALUE lines):
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_ACCOUNT_ID=...
Create the token at dash.cloudflare.com → My Profile → API Tokens with the Cloudflare Pages: Edit permission. Nothing else is needed.
As a library
from cf_publish import deploy, PagesError
result = deploy("./public", "my-site", on_progress=print)
print(result.url, result.uploaded, result.duration)
The core raises PagesError on expected failures and never calls
sys.exit() or prints, so it embeds cleanly in build scripts and GUIs.
Notes and caveats
- Unofficial. This project is not affiliated with Cloudflare. It speaks
the same semi-official Direct Upload endpoints wrangler uses internally
(
upload-token/check-missing/upload/upsert-hashes). If Cloudflare changes them, fall back to wrangler or the Git integration — the hash algorithm is pinned by a fixed-value test so a breakage is caught loudly, not silently. - Hidden files and directories (names starting with
.) are never uploaded. - Symlinks are followed and served as copies (Pages has no symlinks); cycles are detected and broken.
R2 sync
export R2_ACCESS_KEY_ID=... # R2 S3-API token (dashboard -> R2 -> Manage API Tokens)
export R2_SECRET_ACCESS_KEY=... # NOT the Pages token
export CLOUDFLARE_ACCOUNT_ID=...
cf-publish r2 sync ./data my-bucket/some/prefix [--delete] [--dry-run]
Diff-syncs a folder to an R2 bucket over the S3-compatible API — SigV4 is
implemented with the standard library, so still just two dependencies.
Unchanged files (remote ETag == local MD5) are skipped; --delete removes
remote objects that no longer exist locally. Single-PUT only, so objects are
capped at ~5 GB (no multipart yet). Pairs with the Pages command: site on
Pages, data on R2 (free egress), one CLI.
Roadmap
- R2 multipart uploads (>5 GB objects).
Deployment list / rollback is intentionally out of scope: the Cloudflare dashboard ships both ("Rollback to this deployment"), so a CLI duplicate adds nothing.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cf_publish-0.2.2.tar.gz.
File metadata
- Download URL: cf_publish-0.2.2.tar.gz
- Upload date:
- Size: 34.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
817a5de2d0610bcf0bdaff18fe453e0f71ce1e614bf17acc02ad7860e8255dfd
|
|
| MD5 |
2d1997b8ab3e3af779bce3378a051a4c
|
|
| BLAKE2b-256 |
38747eddac23170b24b142d82dec7016d325e1bd75e8d43c7528059379e32006
|
Provenance
The following attestation bundles were made for cf_publish-0.2.2.tar.gz:
Publisher:
publish.yml on aiseed-dev/cf-publish
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cf_publish-0.2.2.tar.gz -
Subject digest:
817a5de2d0610bcf0bdaff18fe453e0f71ce1e614bf17acc02ad7860e8255dfd - Sigstore transparency entry: 2189607977
- Sigstore integration time:
-
Permalink:
aiseed-dev/cf-publish@df7a83d687fb69dde41979e42155f7f154ef4b97 -
Branch / Tag:
refs/tags/v0.2.2 - Owner: https://github.com/aiseed-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@df7a83d687fb69dde41979e42155f7f154ef4b97 -
Trigger Event:
push
-
Statement type:
File details
Details for the file cf_publish-0.2.2-py3-none-any.whl.
File metadata
- Download URL: cf_publish-0.2.2-py3-none-any.whl
- Upload date:
- Size: 16.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f9258c4395169afe197fc090fce2d13e3997db533e7a24c9163a20b1e49834a0
|
|
| MD5 |
47f88e1a26e89dc1148e85c0aaa59fe4
|
|
| BLAKE2b-256 |
7ad24185c3a588a509e9af8da9486f8f61ff0c5dfc8a8c50a9578667b623958e
|
Provenance
The following attestation bundles were made for cf_publish-0.2.2-py3-none-any.whl:
Publisher:
publish.yml on aiseed-dev/cf-publish
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cf_publish-0.2.2-py3-none-any.whl -
Subject digest:
f9258c4395169afe197fc090fce2d13e3997db533e7a24c9163a20b1e49834a0 - Sigstore transparency entry: 2189607991
- Sigstore integration time:
-
Permalink:
aiseed-dev/cf-publish@df7a83d687fb69dde41979e42155f7f154ef4b97 -
Branch / Tag:
refs/tags/v0.2.2 - Owner: https://github.com/aiseed-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@df7a83d687fb69dde41979e42155f7f154ef4b97 -
Trigger Event:
push
-
Statement type: