Guide
Deployment guide
SSG can package a generated site, publish it directly, or run as a GitHub Action. Deployment always happens after generation and enabled post-processing, so providers receive the final output tree.
The 404 page
Static hosts answer an unmatched path by falling back to the site's
index.html — with a 200 — unless the output contains a 404.html. Every
dead URL then looks to a crawler like another live copy of the home page, which
is why SSG generates a minimal 404.html when the site does not provide one.
To own it, add a page slugged 404; it renders to /404.html and takes
precedence. To suppress it entirely, set not_found_off: true.
Production build
A conservative production command is:
1ssg my-blog simple example.com \
2 --clean --minify-all --fingerprint --check-links=strict
Review output/ locally before enabling native deployment. Deployment does not
replace validation of provider configuration, redirects, custom domains or DNS.
Archives
Archives are written beside the project using the configured domain as their base filename:
| Configuration | CLI | Output |
|---|---|---|
zip: true |
--zip |
<domain>.zip |
targz: true |
--targz |
<domain>.tar.gz |
tarxz: true |
--tarxz |
<domain>.tar.xz |
Multiple archive formats can be enabled in one build. Archives contain the output tree and can be uploaded manually to any static host.
Build cache in CI
Every SSG disk cache lives under one root, .ssg-cache/ — processed images,
external-source payloads and AI answers. Persisting that directory between CI
runs skips WebP/AVIF reconversion, re-fetching remote data inside its TTL and
re-querying AI models. With GitHub Actions this is one step, no SSG
configuration at all:
1- uses: actions/cache@v4
2 with:
3 path: .ssg-cache
4 key: ssg-cache-${{ runner.os }}-${{ hashFiles('content/**', '.ssg.yaml') }}
5 restore-keys: ssg-cache-${{ runner.os }}-
Entries are content-addressed, so a stale restore is harmless — anything
outdated is simply not referenced, and ssg cache gc (or a build with
--images-gc) reclaims it. Inspect what the cache holds with
ssg cache stats; start fresh with ssg cache clean.
Native deployment model
1deploy: cloudflare
2deploy_project: my-site
3deploy_branch: main
4deploy_target: ""
Equivalent CLI flags are --deploy, --deploy-project, --deploy-branch and
--deploy-target.
Secrets are read from environment variables. Do not put tokens, passwords or
private keys in .ssg.yaml, Markdown content, command history or a committed
workflow.
| Provider | deploy value |
Project | Target | Credentials |
|---|---|---|---|---|
| Cloudflare Pages | cloudflare |
Pages project name | — | CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID |
| GitHub Pages | github-pages |
— | Git remote; defaults to origin |
GITHUB_TOKEN for HTTPS or normal Git/SSH credentials |
| Netlify | netlify |
Site ID | — | NETLIFY_AUTH_TOKEN |
| Vercel | vercel |
Project ID/name | — | VERCEL_TOKEN; optional VERCEL_ORG_ID |
| FTP | ftp |
— | ftp://[user@]host[:port]/path |
FTP_USERNAME, FTP_PASSWORD |
| SFTP | sftp |
— | sftp://[user@]host[:port]/path |
SSH environment described below |
Accepted aliases include cloudflare-pages, github, gh-pages and ssh,
but canonical names are recommended in durable configuration.
Cloudflare Pages
Create the Pages project first and use an API token with permission to edit it:
1export CLOUDFLARE_API_TOKEN=...
2export CLOUDFLARE_ACCOUNT_ID=...
3
4ssg my-blog simple example.com \
5 --deploy=cloudflare \
6 --deploy-project=my-site
--deploy-branch optionally selects the Pages branch. SSG uses Cloudflare's
Direct Upload API, hashes the output manifest and uploads the required files; it
does not require Wrangler.
Generated _headers and _redirects
Every build writes _headers and _redirects into the output directory,
whether or not you deploy to Cloudflare Pages — they are inert anywhere else.
Pages applies them verbatim, so their content is the site's security and
caching policy, and worth knowing before a page appears to go stale.
Security headers, applied to /*:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
X-XSS-Protection |
1; mode=block |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
geolocation=(), microphone=(), camera=() |
Cache policy:
| Path | Cache-Control |
|---|---|
/css/*, /js/*, /images/*, /media/* |
public, max-age=31536000, immutable |
/*.html and / |
public, max-age=3600 |
The one-year immutable on assets assumes their filenames change when their
contents do. That is exactly what --fingerprint and the image pipeline's
content-addressed names guarantee. Without fingerprinting, a CSS or JS file
edited in place keeps its name and can be served from a browser cache for a
year — enable fingerprint: true for any site that deploys more than once,
or override the policy with headers: (below).
None of this applies while a preview is running. --watch --http and
ssg mcp --http serve every response Cache-Control: no-cache and drop the
browser's If-None-Match / If-Modified-Since, so a rebuilt stylesheet is on
screen after a reload instead of an hour (or a year) later (#185). It is the one
mode where a cache only costs: the file is regenerated seconds after it is
fetched. no-cache still permits storing and revalidating — the preview answers
304 for anything genuinely unchanged.
_redirects is generated from two sources (GO-063):
- the
redirects:config section — explicit rules; and - frontmatter
aliases:, emitted as301s.
Rules accept exact paths, /old/* splats (:splat in the destination) and the
status codes 301, 302, 307, 308 and 410. Exact redirect chains are
flattened at build time — A → B → C is written as A → C and B → C, so a
visitor never takes more than one hop (the chained-redirect SEO penalty). By
default each alias also writes a client-side meta-refresh stub page as a
fallback for non-Cloudflare hosts; alias_stubs: false keeps only the
_redirects entries.
1redirects:
2 - from: /old-pricing
3 to: /pricing
4 # status defaults to 301
5 - from: /blog/*
6 to: /articles/:splat
7 status: 301
8 - from: /discontinued
9 to: /
10 status: 410
Netlify uses the identical _redirects format, so the same file works there
unchanged. Vercel needs a vercel.json you provide yourself; SSG does not
generate one.
The built-in server serves both files
Since 1.8.47, ssg --http and ssg serve read <output>/_redirects and
<output>/_headers and answer with them, re-reading both after every rebuild.
So a redirect can be checked before it is published — which matters most where
it is easiest to get wrong: after a migration, where every rule is a URL
somebody else published, and where a site whose redirects are all broken looks
perfect from the inside.
Nothing new is declared for this. The files are already written on every build;
the server simply reads what is there, the same way endpoints: are served from
the same declaration the platform compiles.
The semantics are Cloudflare Pages', because that is what generation targets first:
- Redirect rules are evaluated before static assets, so a rule shadows a file sitting at the same path.
- Within the file, order is honoured and the first match wins — also Netlify's rule. The generator already emits exact rules before wildcard ones so a wildcard cannot swallow the specific rules beneath it.
- Exact paths,
/old/*splats with:splat,:placeholdersegments, and301/302/303/307/308/410. A410answers410with noLocation. - A
!force marker parses and is accepted. Under these semantics everything shadows already, so it changes nothing locally. - A malformed line is skipped with one warning naming it and the rest of the file stays live. A file the build wrote must never be a file the server refuses to start over.
For _headers, a response whose path matches a pattern gets that block's
headers, first matching value winning per header name — Cloudflare's rule, which
is why the block order in the generated file is deliberate. The file wins over
the server's own security headers: that is what the deployed site would serve,
and a preview whose headers differ from production is the gap this closes.
One consequence worth stating: the default _headers caches / and /*.html
for an hour, so the preview now says so too. Pages carrying the live-reload
script are exempted — a reload served from the cache would show the previous
build.
Request order in the built-in server is: endpoints: → _redirects →
_headers → the static tree.
Importing an existing rule set. ssg import redirects turns a JS
redirects() array (as some frameworks export in their config) into the
redirects: block above:
1# reliable: from a JSON dump of the array
2node -e "import('./next.config.js').then(async c => console.log(JSON.stringify(await c.default.redirects())))" > redirects.json
3ssg import redirects --from-json redirects.json
4
5# or parse the config file heuristically
6ssg import redirects next.config.ts
Entries it cannot translate (conditional has/missing, template-literal
paths, regex-constrained params) are reported on stderr, never silently
dropped — Cloudflare's _redirects cannot express them anyway.
Overriding _headers
The headers: config section overrides or extends the generated blocks per
path pattern. A pattern that appears in headers: replaces that block's
headers; unknown patterns are appended. headers_defaults_off: true drops the
built-in security/cache blocks entirely.
1headers:
2 /*:
3 Content-Security-Policy: "default-src 'self'"
4 /api/*:
5 Access-Control-Allow-Origin: "*"
With an empty headers: the file is byte-for-byte what SSG has always
generated.
Dynamic endpoints beside the static site
For payments, forms, dynamic pricing or server-side tracking, add a Cloudflare
Pages Function with the worker: section — see WORKERS. SSG
copies the Function into the output, generates _routes.json, and deploys it
(via wrangler pages deploy for a functions/ tree, or Direct Upload for a
prebuilt _worker.js).
A page still shows its old content
HTML is cached at the edge for an hour. After renaming a page — changing
post_url_format, adding a permalinks pattern — the old URL keeps
answering 200 from cache for up to that hour even though the new deployment
no longer contains it, and the new URL is live immediately. Check with
curl -sI <url> | grep -i age before concluding the deploy failed; purge the
cache in the Cloudflare dashboard if you cannot wait.
This project's own documentation site
ssg.tradik.com is built from this repository by
.github/workflows/docs-site.yml and is
a working example of everything above:
- The site has no content tree.
docs-site.yamlpullsdocs/in throughcontent_sources, so editing a guide is the whole publishing workflow. - It is built with the
ssgbinary from the commit being deployed, not the released action, so the site doubles as an integration test ofmainon real content — and so it can use configuration keys newer than the last release. shortcode_errors: strictand--check-links=strictgate the deploy: an unrenderable shortcode or a dead internal link fails the run, and SSG deploys only after a successful generate, so a broken page never reaches the CDN.cwebpis installed in the runner because the hero image is encoded as WebP.
Setup is two repository secrets and nothing else:
| Secret / variable | Required | Purpose |
|---|---|---|
CLOUDFLARE_API_TOKEN |
yes | Cloudflare Pages: Edit. Attaching the custom domain additionally needs Zone:DNS:Edit on that zone. |
CLOUDFLARE_ACCOUNT_ID |
yes | Account that owns the project |
CLOUDFLARE_PAGES_PROJECT |
no | Pages project name (default ssg-docs) |
DOCS_DOMAIN |
no | Custom domain (default ssg.tradik.com) |
The workflow creates the Pages project and attaches the custom domain on its
first run, and leaves both alone afterwards — there is nothing to click in the
dashboard. Domain attachment is deliberately non-fatal: the deployment is
already live on <project>.pages.dev by then, and the usual failure is a token
without Zone:DNS:Edit, which is a permissions decision rather than a build
problem. The job summary says which of the two happened.
A pull request runs the same build with --check-links=strict and stops there:
nothing is created in Cloudflare and nothing is uploaded, but a dead link or an
unrenderable shortcode in a new post fails the check before review rather than
after the merge. Only a push to the default branch deploys.
workflow_dispatch only becomes available once the workflow file is on the
default branch, so the first manual run happens after a merge.
GitHub Pages
1export GITHUB_TOKEN=...
2
3ssg my-blog simple example.com \
4 --deploy=github-pages \
5 --deploy-target=https://github.com/example/site.git \
6 --deploy-branch=gh-pages
The target defaults to the current repository's origin, and the branch
defaults to gh-pages. SSG creates an isolated Git repository inside the output
directory, commits the generated tree and force-pushes a single commit.
github-pagesintentionally rewrites the target branch history. Use a branch dedicated to generated output, never a source or shared development branch.
For HTTPS remotes, GITHUB_TOKEN is passed as an HTTP authorization header and
is not embedded in the remote URL. SSH remotes use the normal Git/SSH setup.
The git executable must be available.
Netlify
1export NETLIFY_AUTH_TOKEN=...
2
3ssg my-blog simple example.com \
4 --deploy=netlify \
5 --deploy-project=your-site-id
NETLIFY_SITE_ID may replace --deploy-project. SSG declares a digest manifest
through Netlify's deploy API and uploads only files Netlify reports missing.
The Netlify CLI is not required.
Vercel
1export VERCEL_TOKEN=...
2export VERCEL_ORG_ID=...
3
4ssg my-blog simple example.com \
5 --deploy=vercel \
6 --deploy-project=my-project
VERCEL_PROJECT_ID may replace --deploy-project; VERCEL_ORG_ID selects the
team scope and is optional for an unscoped account. SSG uploads content-addressed
files and creates a production deployment. The Vercel CLI is not required.
FTP
1export FTP_USERNAME=deploy
2export FTP_PASSWORD=...
3
4ssg my-blog simple example.com \
5 --deploy=ftp \
6 --deploy-target=ftp://ftp.example.com/public_html
The username may be included in the target URL; otherwise FTP_USERNAME is
used, falling back to anonymous. The default port is 21. SSG creates remote
directories where possible and uploads every regular output file.
FTP does not encrypt credentials or content. Prefer SFTP when the host supports it.
SFTP
Password authentication:
1export SSH_USERNAME=deploy
2export SSH_PASSWORD=...
3
4ssg my-blog simple example.com \
5 --deploy=sftp \
6 --deploy-target=sftp://server.example.com/var/www/site
Key authentication:
1export SSH_KEY_FILE="$HOME/.ssh/id_ed25519"
2export SSH_KEY_PASSPHRASE=...
3
4ssg my-blog simple example.com \
5 --deploy=sftp \
6 --deploy-target=sftp://[email protected]/var/www/site
SFTP variables:
| Variable | Meaning |
|---|---|
SSH_USERNAME |
Used when the URL has no username |
SSH_PASSWORD |
Password authentication; takes priority over a key |
SSH_KEY_FILE |
Private key; defaults to ~/.ssh/id_rsa |
SSH_KEY_PASSPHRASE |
Optional encrypted-key passphrase |
SSH_KNOWN_HOSTS |
Host database; defaults to ~/.ssh/known_hosts |
The default port is 22. Host keys are always verified; unknown hosts are rejected. Add the expected key out of band, for example after independently checking its fingerprint:
1ssh-keyscan server.example.com >> ~/.ssh/known_hosts
GitHub Action
Use the stable major reference to receive compatible v1 updates:
1name: Build site
2
3on:
4 push:
5 branches: [main]
6
7jobs:
8 build:
9 runs-on: ubuntu-latest
10 permissions:
11 contents: read
12 steps:
13 - uses: actions/checkout@v6
14 - name: Build with SSG
15 id: ssg
16 uses: spagu/ssg@v1
17 with:
18 source: my-blog
19 template: simple
20 domain: example.com
21 clean: "true"
22 minify: "true"
Pin a full released tag instead of @v1 when reproducibility is more important
than automatic compatible updates. Use @main only to test unreleased changes.
Pin the binary too. The action's version input defaults to latest, so
every deploy silently picks up the newest ssg release the moment it ships —
including behaviour changes. For production sites, pin it:
1- uses: spagu/ssg@v1
2 with:
3 version: v1.8.5 # exact ssg release used for the build
Since v1.8.5 the action logs the resolved version on every run (a ::notice::
when latest was used) and exposes it as the version output, so unpinned
builds are at least traceable.
Building a config-driven site
Point the action at your config file. Everything a real site keeps there —
redirects:, worker:, variables:, the check_* validators — has no input
equivalent, so this is the only way the action can build it:
1- uses: spagu/ssg@v1
2 with:
3 config: .ssg.yaml
4 deploy: cloudflare
5 deploy-project: my-site
With config set, source, template and domain are optional — the config
supplies them, and repeating them here is how the two drift apart. Other inputs
are still passed through as flags; the CLI resolves flag-versus-config
precedence, so a flag wins over the same setting in the file.
Action inputs
The action intentionally exposes a stable subset of the complete CLI:
| Input | Required | Default | Meaning |
|---|---|---|---|
config |
no | — | Path to .ssg.yaml/.toml/.json. Makes the three below optional |
source |
unless config |
— | Content source name |
template |
unless config |
simple |
Theme name |
domain |
unless config |
— | Canonical host |
version |
no | latest |
Binary release to download |
content-dir |
no | content |
Content root |
templates-dir |
no | templates |
Theme root |
output-dir |
no | output |
Generated destination |
webp |
no | false |
Enable WebP conversion |
webp-quality |
no | 60 |
WebP quality |
zip |
no | false |
Create ZIP archive |
minify |
no | false |
Enable all minification |
clean |
no | false |
Clean before build |
engine |
no | go |
Template engine |
online-theme |
no | empty | Theme download URL |
deploy |
no | empty | Native provider |
deploy-project |
no | empty | Provider project/site |
deploy-branch |
no | empty | Provider branch |
deploy-target |
no | empty | Git/FTP/SFTP target |
Inputs are passed through environment variables and validated before being added to the command argument array.
Action outputs
| Output | Meaning |
|---|---|
output-path |
Generated site directory |
zip-file |
ZIP path when zip is enabled |
zip-size |
ZIP size in bytes |
version |
Resolved ssg version used for the build (GO-052) |
Example artifact upload:
1- uses: actions/upload-pages-artifact@v4
2 with:
3 path: ${{ steps.ssg.outputs.output-path }}
Native deployment from Actions
1- uses: spagu/ssg@v1
2 with:
3 source: my-blog
4 template: simple
5 domain: example.com
6 clean: "true"
7 minify: "true"
8 deploy: cloudflare
9 deploy-project: my-site
10 env:
11 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
12 CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Provider credentials must be repository or environment secrets referenced in
env, not action inputs. A complete example is available at
examples/workflows/cloudflare-pages.yml.
Deployment checklist
- Run a clean production build.
- Run
--check-links=strict. - Inspect generated canonical URLs and redirects.
- Confirm the provider project/site ID.
- Store credentials only in the runtime environment or CI secret store.
- Use a least-privilege token.
- For GitHub Pages, confirm the target is a disposable generated branch.
- For SFTP, verify and pin the server host key.
- Test custom domains, HTTPS and cache headers after publishing.