Skip to content
WebShield Docs

Static sites

How to create a static site, upload files, and manage them in WebShield.

You can host a static site right inside WebShield — no server of your own. Files live with us and are served through the same protection layer as proxied sites: CDN cache, WAF, bot protection.

  • Static site hosting must be available on the domain plan. Storage size and the maximum file size depend on the plan.
  • A site lives on the domain apex or on any subdomain. No record is needed in advance; if the name already has an A, AAAA, or CNAME, it comes back once the site is removed.
  • Proxying must be disabled for the name. One hostname is either proxied or hosts a static site.
  1. Open the Sites section.
  2. Click Create and pick a name from the list or New name…. Names that are already proxied or taken are marked and unavailable.
  3. The site is created in the Disabled state — it is not visible to visitors until you publish it.

index.html in the site root serves as the start page. Files can be uploaded in several ways:

  • Drag and drop — drag files or folders into the upload area. The subdirectory structure is preserved.
  • Pick files / Pick folder — upload via the selection dialog. When a folder is selected, relative paths are preserved.
  • Into a specific folder — the upload button on a folder row in the file list adds the selected files directly to it.
  • ZIP archive — uploading an archive completely replaces the current site content. The other methods work in merge mode: new files are added and matching paths are overwritten.

Files are displayed as a tree with subdirectories. Folders show the total size of their content.

  • Download: a file is downloaded as is; a folder or the whole site (the ZIP button in the list header) is downloaded as a ZIP archive.
  • View: text files (HTML, CSS, JS, JSON, and others, up to 512 KB) open in a preview window.
  • Delete: select files and folders and click Delete selected. A folder is deleted together with its content. A single item can be deleted with the button in its row.
  • Updating a file: upload a file with the same path — it overwrites the old one.

Uploaded files go into a draft (the working area) shown in the file manager. Visitors see the last published version, not the draft.

The Publish button:

  1. Takes an immutable snapshot of the draft as a new version (atomically — visitors never see a half-updated site).
  2. Switches the host DNS record to the WebShield infrastructure. The original record is saved and will be restored when the site is disabled.
  3. Issues a TLS certificate (if HTTPS is enabled and the domain is delegated).
  4. Makes the new version available to visitors.

Disable unpublishes the site and restores the original DNS record; the files are kept. Deleting the site removes the files and fully releases the hostname.

Content is cached on edge nodes. Every publish is a new version, so the cache is invalidated automatically: visitors receive the new content immediately after publishing, with no manual purge. If you changed draft files and want visitors to see them, click Publish.

  • Site generator — pick a preset (Hugo, Jekyll, Astro, Gatsby, Next.js export, SPA, plain HTML). The preset sets sensible defaults for the other options, which you can still tweak.
  • Clean URLs — how pretty paths resolve to files, matching your generator:
    • Directory index — /about → /about/index.html (Hugo, Jekyll, Astro, Eleventy, Gatsby);
    • HTML extension — /about → /about.html, then /about/index.html (Next.js export);
    • Exact path only — no guessing.
  • 404 page — document served for missing paths (e.g. 404.html), returned with a 404 status. Empty — pass the origin response through. A separate page for a section — say, for the Russian version of the site — comes from a 404 rule in _redirects.
  • SPA mode — serve index.html (with a 200 status) for unknown paths. Enable it for single-page applications with client-side routing.
  • Serve over HTTPS — issue a certificate and serve the site over HTTPS with a redirect from HTTP.
  • Bot protection — Off, Browser check, or Captcha check. Works the same way as for proxied sites.

Settings changes for a published site are applied automatically.

A page moved, you switched generators, the section tree got reshuffled — and the old links are already out there in search results and other people’s articles. Letting them hit a 404 is a waste. Put a _redirects file into the site root:

# old addresses → new ones
/old-page /new-page 301
/blog/* /posts/:splat 301
/docs/:section/ /guide/:section/ 302
/go/telegram https://t.me/example
# an SPA inside a section
/app/* /app/index.html 200
# a 404 page for every language
/ru/* /ru/404.html 404
/en/* /en/404.html 404
# from the root — to the version in the browser's language
/ /ru/ 302 Language=ru,uk,be
/ /en/ 302

One line, one rule: from, to and, optionally, a status. No status means 301. Blank lines and lines starting with # are skipped. The format matches Netlify and Cloudflare Pages, so an existing file usually moves over as is.

Rules take effect when you publish. The file itself is never served — nobody can open it at /_redirects. The number of active rules is shown in the site settings.

From:

  • * matches the rest of the path and goes last only: /blog/* catches both /blog and /blog/2024/hello. In the destination it becomes :splat.
  • :name matches exactly one segment: /docs/:section catches /docs/install, not /docs/install/linux.
  • Latin letter case and the trailing slash don’t matter: /Old-Page/ hits the /old-page rule.
  • Matching on a query string isn’t supported. The visitor’s query is carried over, though: /old-page?utm=mail lands on /new-page?utm=mail. If the destination has its own ?…, only that one stays.

To, and with which status:

  • 301, 308 — moved for good; 302, 303, 307 — temporarily. The destination is a path on this site or a full https://… address.
  • 200 serves a file of this site under a different address, with no redirect. It kicks in only when there’s no file at the requested address: /app/* won’t touch a real /app/logo.svg.
  • 404 sets a “not found” page for a section, see below.
  • Rules are checked top to bottom, the first match wins. 3xx redirects fire before files are served: even if the old page is still there, the visitor goes to the new one.

What’s not here — and where to find it:

  • /* /404.html 404 is the 404 page in the site settings.
  • /* /index.html 200 is SPA mode.
  • Sending paths to your own server (/api/* https://api.example.com/:splat 200) is API on the same domain.
  • Conditions on country or cookies aren’t supported.

On a multilingual site, “page not found” should speak the reader’s language too. A rule with status 404 gives a section a page of its own:

/ru/* /ru/404.html 404
/en/* /en/404/index.html 404

Any address under /ru/ with neither a file nor a matching 200 rule gets the section’s page — with a 404 status, as it should. Addresses outside the sections get the 404 page from the site settings. Sections are checked top to bottom: if /ru/docs/* needs a different page than /ru/*, put it higher.

  • The source is a section ending in *. The whole site (/*) is set up elsewhere — in the 404 page field.
  • The destination is an .html page of this site, without :splat or :name. If the file isn’t in the upload, publishing stops with an error: a broken link to a 404 page would otherwise go unnoticed.
  • 404 rules don’t work in SPA mode — the app answers every address. A site with such rules won’t switch to SPA mode.

The Language= condition sends readers to the version in their language:

/ /ru/ 302 Language=ru,uk,be
/ /en/ 302

The language is the one listed first in the browser settings. ru covers both ru-RU and ru-BY; pt-BR covers Brazilian Portuguese only. If the condition doesn’t match, checking moves on down the list, so put the catch-all rule without a condition below.

We’re deliberately stricter than Netlify here:

  • Temporary statuses only: 302, 303, 307. Browsers remember a permanent redirect forever, and a reader who once came with a Russian browser could never open the English version again.
  • Exact addresses only, no * or :name — usually the root /. A rule like /* → /ru/:splat would keep people off the other language’s pages even when they follow a direct link.

Search crawlers don’t send a language, so the condition never moves them and they see the page as is. Point them to the language versions with hreflang on your pages.

We don’t swallow mistakes in the file: publishing stops and the response names the line and the reason. Meanwhile the site keeps running on the previous version. Limits: up to 2000 rules, of which up to 100 may use * or :name; the file is capped at 128 KB.

Is only contact form processing needed? You don’t need a server for that, see Forms on a static site.

A generated site is rarely fully static: a contact form, a subscription, a search box — something always posts a request. The usual answer is a separate api.example.com, and that is where CORS, preflight requests and SameSite=None cookies begin.

Keeping everything on one name is simpler: we serve the pages, the listed paths go to your server.

  1. Open the site under Sites and find the API on this domain block.
  2. Under Routes, list the paths, one per line: /api/, /form, /feedback.
  3. Under Your server address, say where to send them: api.example.com or 203.0.113.10, with a port if needed.
  4. Save. Everything else is still served from our storage.

Worth knowing:

  • The trailing slash matters. /api also covers /apidocs; /api/ covers nested paths only. The root path is not allowed: a whole site on your server is a proxied host, not a static site.
  • We pass the site’s Host header. Your server must answer for the site name, not only for its own.
  • The browser check is never shown on these paths — by its nature it breaks fetch and SDKs. Malicious request filtering, rate limits and address blocks still apply there: the WAF stays in place.
  • The server address must be public. Private and local addresses are rejected.
  • If the address stops resolving, the site keeps opening — only the API paths go down. We pick up an IP change on your server ourselves, within a few hours.
  • The hop to your server uses HTTP/1.1; WebSocket is not supported on these paths.

Building and publishing on every push is a single step with the CLI. What to prepare:

  1. On the site card, under Deploy tokens, create a token. It can do exactly two things — upload files and publish this site; site settings, the domain and other sites stay out of reach. The full token is shown once, and you can revoke it in the same place.
  2. Store the token in your repository secrets as WS_TOKEN — the CLI reads that variable on its own.
  3. Add two steps to the pipeline: install the CLI, then publish.

All three read the same workflow syntax.

.github/workflows/deploy.yml
name: Deploy site
on:
push:
branches: [main]
# Several pushes in a row: cancel the older runs, let the last one publish.
concurrency:
group: deploy-site
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
env:
WS_TOKEN: ${{ secrets.WS_TOKEN }} # the deploy token; the CLI picks it up here
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 1 # the build does not need history
- run: npm ci && npm run build # your build → ./dist
- name: Install the CLI
run: curl -fsSL https://raw.githubusercontent.com/webshieldpro/webshield-cli/main/install.sh | sh
- name: Publish
run: ~/.local/bin/webshield sites publish www.example.com --dir ./dist
.gitlab-ci.yml
deploy:
image: node:24
rules:
- if: $CI_COMMIT_BRANCH == "main"
interruptible: true # a new push cancels the running publish
script:
- npm ci && npm run build # your build → ./dist
- curl -fsSL https://raw.githubusercontent.com/webshieldpro/webshield-cli/main/install.sh | sh
- ~/.local/bin/webshield sites publish www.example.com --dir ./dist

Add the token under Settings → CI/CD → Variables as WS_TOKEN with Masked and Protected ticked: masked keeps it out of the job log, protected keeps it out of builds on other branches.

If you use object storage, you can publish a site straight from one of your own buckets — the content is not uploaded through WebShield. Upload the built site to any of your buckets, into any folder, with any S3 tool (rclone, aws s3, s3cmd, CI artifacts), then publish it from there.

  • In the dashboard: open the site card, find Publish from an S3 bucket, pick the bucket, enter the prefix (folder), and click Publish from bucket.

  • In the CLI:

    Terminal window
    webshield sites publish-from-bucket www.example.com --bucket web --path public/

The contents of the chosen prefix replace the site and are published as a new immutable version. Plan size limits are enforced at publish time. The source keeps living in your bucket (billed as object storage); only the served copy is part of static hosting — you may delete the source bucket afterwards, the published snapshot survives.