Deploying to production

A Piral application has two independent deployment streams, and keeping them separate is the whole point:

  • The app shell is a static site. You build it once and host the dist/release/ output like any SPA. It changes rarely.
  • Pilets are published to a feed service, not bundled into the shell. They ship continuously, on each team's own schedule, without ever redeploying the shell.

This page covers both, plus the server configuration and caching that make it reliable.

App shell project built once, changes rarely npm run build Static host / CDN nginx · S3+CloudFront · Netlify Pilet projects shipped continuously, per team pilet publish Feed service Piral Cloud or custom Users' browser shell loads, then pilets
Two independent pipelines: the shell deploys as a static site, pilets publish to the feed — and they meet only in the browser.

Deploying the app shell

npm run build produces dist/release/ — plain static files (HTML, JS, CSS, assets). Serve them from any static host or web server. There is one hard requirement: because routing is client-side, the server must fall back to index.html for any path that isn't a real file, so deep links and refreshes work.

Self-hosted: nginx

A complete server block for a self-hosted static deployment, with SPA fallback, compression, and correct caching:

server {
  listen 443 ssl http2;
  server_name app.example.com;
  root /var/www/my-portal;          # contents of dist/release/
  index index.html;

  gzip on;
  gzip_types text/css application/javascript application/json image/svg+xml;
  gzip_min_length 1024;

  # Hashed assets never change — cache them aggressively
  location /assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    try_files $uri =404;
  }

  # The HTML entry point must never be cached
  location = /index.html {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
  }

  # SPA fallback: every other route renders the app shell
  location / {
    try_files $uri $uri/ /index.html;
  }
}

Self-hosted: Apache / IIS

For Apache, an .htaccess in the web root handles the fallback:

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

For IIS, add a rewrite rule in web.config that maps non-file requests to /index.html. The principle is identical: real files are served as-is, everything else returns the shell.

Managed static hosts

Most platforms need only a one-line fallback rule:

HostSPA fallback configuration
Netlify_redirects: /* /index.html 200
Vercelvercel.json rewrite: "source": "/(.*)", "destination": "/index.html"
GitHub PagesCopy index.html to 404.html
Azure Static Web Appsstaticwebapp.config.json navigationFallback/index.html
AWS S3 + CloudFrontSet the error document to index.html (403/404 → 200)

App shell via GitHub Actions

name: Deploy app shell
on:
  push:
    branches: [main]
permissions:
  contents: write
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm run build
      - uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./dist/release

App shell to AWS S3 + CloudFront

aws s3 sync dist/release s3://my-bucket --delete
aws cloudfront create-invalidation --distribution-id $CF_DIST_ID --paths "/*"

Caching strategy

Get these headers right and the app feels instant while still updating predictably:

# Hashed asset bundles (app shell + pilets) — cache forever
Cache-Control: public, max-age=31536000, immutable

# index.html — never cache, so new releases are picked up immediately
Cache-Control: no-cache, no-store, must-revalidate

# The feed response — short TTL so rollouts/rollbacks propagate fast
Cache-Control: public, max-age=30, stale-while-revalidate=60

The feed TTL is the lever that controls how quickly a pilet rollout or rollback reaches users — typically within 30–60 seconds.

Deploying pilets

Pilets never go through the app shell's pipeline. Each pilet builds and publishes to the feed independently:

name: Publish pilet
on:
  push:
    branches: [main]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm test
      - run: npx pilet build
      - run: npx pilet publish --url ${{ vars.FEED_URL }}/api/v1/pilets --api-key ${{ secrets.FEED_API_KEY }}

The feed stores the bundle on your CDN and records the new version; the next feed response includes it. Where the feed lives — Piral Cloud or a custom feed service — doesn't change the publish step.

Rollback

Because the shell only knows the feed URL, rolling back a pilet never touches the shell. Point the feed at the previous version and users pick it up on their next load:

Feed typeHow to roll back
Piral CloudOne click in the dashboard
Self-hosted feedSet the active version in your database
Static JSON feedEdit the version / link in the JSON file

No app shell rebuild, no git revert, no redeploy.