Filesystem-backed preview control for static SPAs

Run Netlify-like preview flows on your own server

Selflify turns your existing preview directories into a manageable control surface: one panel for sites, deploys, auth and runtime settings on your own server.

Free & open source under the MIT license

  • No database, no control-plane lock-in, no hidden state.
  • Per-site deploy visibility, preview protection and delete flows in one place.
  • Ready for server bootstrap, GitHub rollout and local self-hosted operation.

Feature set

Everything stays close to the server reality

Selflify stays intentionally literal: the panel reflects what is on disk, what Caddy serves and which records point traffic at the box.

Single source of truth

selflify.config.json keeps panel state, site definitions and operational metadata together.

Zero-downtime config apply

Changes validate and reload through the Caddy admin API instead of brute-force restarts.

Preview auth built in

Set preview login and password in the panel, with caddy hash-password handled under the hood.

Cloudflare-aware

Create sites and keep wildcard DNS aligned with the configured server IP without manual clicks.

Optional stable alias

Add one extra hostname for the main branch while previews stay on the canonical subdomain and wildcard hosts.

Deploy inventory from disk

Stable and preview folders under /var/www become a readable deploy list with size and status.

Operational guardrails

Revision checks, snapshots and orphan cleanup keep destructive actions disciplined.

Bootstrap

Fresh server to running panel in one command

The installer prepares the host, seeds the runtime files and starts the stack. By default, everything lands in /opt/selflify.

curl -fsSL https://github.com/Selflify/Selflify/releases/latest/download/install-selflify.sh | bash

Host bootstrap

Docker, the Compose plugin, project files, .env and the initial runtime config are placed on the server automatically from the release assets.

First-time setup

Open http://<server-ip>/setup to create the account, enter the domain, server IP and Caddy email, then paste the Cloudflare token.

Static landing

The marketing site stays separate, so you can edit it locally and publish dist/landing to GitHub Pages.

Workflow

What daily Selflify operation looks like

After the stack is already installed, the panel becomes the place where you shape site routing, preview access and cleanup without touching raw server config files.

01

Point it at real site folders

Selflify reads stable and preview directories directly from /var/www, so the UI reflects what is actually present on disk instead of mirroring a second deploy database.

02

Create or edit sites from the panel

Add a site, choose its subdomain and main branch, and let Selflify keep routing, wildcard DNS and placeholder content aligned with that config.

03

Control preview access and deploy cleanup

Protect previews with a shared login and password, inspect deploy inventory from disk and remove obsolete preview folders when they are no longer needed.

04

Apply changes without bouncing the stack

Caddy reloads, Cloudflare DNS updates and cleanup jobs all operate against the same file-backed runtime state, with backups and revision checks around writes.

DNS stack

Why Selflify uses Cloudflare DNS together with Caddy wildcard routing

Cloudflare gives Selflify an API for managing DNS records. Caddy serves the actual traffic on your server. The point is not to proxy all traffic through Cloudflare. The point is to keep DNS programmable while Caddy stays in control of routing, reloads and TLS on the box.

Cloudflare is used for DNS, not as the traffic path

Put the domain or subdomain zone under Cloudflare nameservers, but keep Selflify-managed A records in DNS only mode. Requests still go straight to your server instead of being proxied by Cloudflare.

Why Caddy instead of nginx

Selflify rewrites web server config often. Caddy fits that model better because it has a clean admin API for zero-downtime reloads, built-in caddy hash-password for preview auth and a configuration format that is easier to generate safely than a pile of nginx templates.

What Caddy handles on the server

Each site gets a stable hostname like app.example.dev plus a wildcard preview hostname like *.app.example.dev. You can also attach one extra exact alias to the main branch only. Selflify writes one generated Caddy config, and Caddy resolves the right directory without extra per-site nginx vhost files.

What you need to do with the domain

  • Delegate the zone you want Selflify to manage to Cloudflare nameservers.
  • Keep the Selflify A records as DNS only, not proxied.
  • Let Selflify point those records at your server IP automatically.
  • If you attach a separate stable alias, keep its DNS under your own control.

How to get a Cloudflare API token

  1. Open Cloudflare and go to My Profile → API Tokens.
  2. Create a token from the Edit zone DNS template or a custom token.
  3. Grant Zone / DNS / Edit and Zone / Zone / Read.
  4. Scope the token only to the zone used by Selflify.
  5. Paste the token into /setup or later into Settings.

What Selflify updates in Cloudflare

  • The stable record for each site, for example app.example.dev.
  • The wildcard preview record for each site, for example *.app.example.dev.
  • Those records are refreshed when infrastructure settings or site definitions change.

Environment

Configure Selflify through one .env file

Put these variables into .env in the project root. On a server, that is the deployed project directory next to docker-compose.yml.

AUTH_SECRET=replace-me-with-a-long-random-string
SELFLIFY_CONFIG_PATH=./.dev/selflify.config.json
SELFLIFY_CADDY_CONFIG_PATH=./.dev/Caddyfile
SELFLIFY_CADDY_ADMIN_ADDRESS=http://caddy:2019
SELFLIFY_CADDY_CONTAINER=selflify-dev-caddy
SELFLIFY_BACKUP_ROOT=./.selflify/backups
SELFLIFY_BACKUP_KEEP=20
SELFLIFY_MOCK_CLOUDFLARE=1
SELFLIFY_SKIP_CADDY_RELOAD=0
SELFLIFY_PREVIEW_TTL_DAYS=30
SELFLIFY_ORPHAN_TTL_DAYS=30
SELFLIFY_CLEANUP_INTERVAL_SECONDS=3600

Where to place it

Create .env before running docker compose or yarn dev. On a bootstrapped server, that means /opt/selflify by default.

What is actually required

AUTH_SECRET is the critical one. The other values are operational knobs for config paths, Caddy integration, backup retention, Cloudflare mocking and cleanup intervals.

Dev vs server defaults

The example above matches the local fixture stack, so it points at .dev. In production you usually keep only the values that need to be overridden and let Selflify use the seeded runtime/ paths by default.

Integrations

Connect your deploy pipeline to Selflify

Selflify supports two practical integration styles: GitHub workflows with reusable actions, or plain CLI uploads over SSH and rsync.

GitHub

Wire your repository to Selflify preview deploys

Add one small site map file and one preview workflow. The action chain prepares the deploy matrix, uploads preview builds, writes PR comments and can feed the resolved preview URL into e2e tests.

What your repository needs

  • Create .github/preview-sites.json in the application repository.
  • Add a preview workflow under .github/workflows/preview.yml.
  • Store PREVIEW_BASE_DOMAIN, PREVIEW_SERVER_HOST and PREVIEW_SSH_PRIVATE_KEY in GitHub Actions secrets.

How the flow behaves

  • Every configured site gets its own matrix job and upload step.
  • PRs receive a placeholder comment first and the final table after deploys finish.
  • Tests can ask preview-url for the final hostname and wait for the deployed commit to become reachable.
1

Add the site map file

Create .github/preview-sites.json so Selflify knows which sites to build from this repository, which commands to run for each one and where the final static output will be written.

{
  "include": [
    {
      "site": "marketing",
      "node_version": "20",
      "setup_cmd": "",
      "install_cmd": "yarn install --immutable",
      "build_cmd": "yarn build",
      "output": "dist"
    },
    {
      "site": "docs",
      "node_version": "20",
      "setup_cmd": "",
      "install_cmd": "pnpm install --frozen-lockfile",
      "build_cmd": "pnpm build:docs",
      "output": "apps/docs/dist"
    }
  ]
}
2

Add the preview workflow

Create .github/workflows/preview.yml to turn pull requests into preview deploy jobs. This workflow prepares the matrix, uploads the built files to your Selflify server and updates the pull request with the final links.

name: Preview

on:
  pull_request:
  push:

permissions:
  actions: read
  contents: read
  issues: write
  pull-requests: write

jobs:
  prepare:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.preview.outputs.matrix }}
      site_names: ${{ steps.preview.outputs.site_names }}
      path_name: ${{ steps.preview.outputs.path_name }}
      pr_number: ${{ steps.preview.outputs.pr_number }}
    steps:
      - uses: actions/checkout@v4
      - id: preview
        uses: Selflify/preview-prepare-action@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}

  comment_placeholder:
    if: ${{ needs.prepare.outputs.pr_number != '' }}
    needs: prepare
    runs-on: ubuntu-latest
    steps:
      - uses: Selflify/preview-comment-placeholder-action@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          pr-number: ${{ needs.prepare.outputs.pr_number }}
          path-name: ${{ needs.prepare.outputs.path_name }}
          site-names-json: ${{ needs.prepare.outputs.site_names }}

  deploy:
    needs: prepare
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
    name: Deploy ${{ matrix.site }}
    steps:
      - id: deploy
        uses: Selflify/deploy-preview-action@v1
        with:
          node-version: ${{ matrix.node_version }}
          setup-cmd: ${{ matrix.setup_cmd }}
          install-cmd: ${{ matrix.install_cmd }}
          build-cmd: ${{ matrix.build_cmd }}
          site: ${{ matrix.site }}
          path-name: ${{ needs.prepare.outputs.path_name }}
          output: ${{ matrix.output }}
          preview-base-domain: ${{ secrets.PREVIEW_BASE_DOMAIN }}
          preview-server-host: ${{ secrets.PREVIEW_SERVER_HOST }}
          preview-ssh-private-key: ${{ secrets.PREVIEW_SSH_PRIVATE_KEY }}
          pr-number: ${{ needs.prepare.outputs.pr_number }}

      - if: ${{ steps.deploy.outputs.metadata-file != '' }}
        uses: actions/upload-artifact@v4
        with:
          name: preview-metadata-${{ matrix.site }}
          path: ${{ steps.deploy.outputs.metadata-file }}
          if-no-files-found: error

  comment:
    if: ${{ needs.prepare.outputs.pr_number != '' }}
    needs: [prepare, deploy]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: preview-metadata-*
          path: preview-metadata
          merge-multiple: true

      - uses: Selflify/preview-comment-action@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          pr-number: ${{ needs.prepare.outputs.pr_number }}
          path-name: ${{ needs.prepare.outputs.path_name }}
          preview-base-domain: ${{ secrets.PREVIEW_BASE_DOMAIN }}
          metadata-dir: preview-metadata
3

Feed the preview URL into e2e

Use Selflify/preview-url-action when tests need the real preview hostname. The action resolves the final URL and can wait until the exact deployed commit is reachable before your test suite starts in a separate e2e workflow.

jobs:
  e2e:
    needs: deploy
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: yarn install --immutable

      - id: preview
        uses: Selflify/preview-url-action@v1
        with:
          site: marketing
          preview-base-domain: ${{ secrets.PREVIEW_BASE_DOMAIN }}
          wait-mode: exact-sha

      - name: Run e2e suite against the preview
        env:
          PLAYWRIGHT_BASE_URL: ${{ steps.preview.outputs.link }}
        run: yarn playwright test

CLI

Use plain rsync from any CI runner

If you do not want reusable actions, upload preview builds from any CI system with plain shell steps. The only requirement is to sync the build output into /var/www/<site>/<deploy> over SSH.

What your runner needs

  • SSH access to the Selflify server.
  • A build output directory ready for upload.
  • A predictable deploy name such as pr-1234 or a commit SHA.

What you provide

  • PREVIEW_SSH_USER and PREVIEW_SERVER_HOST
  • PREVIEW_SSH_PRIVATE_KEY
  • PREVIEW_BASE_DOMAIN, SITE and DEPLOY_NAME

How the flow behaves

  • The runner uploads static files directly into the preview directory.
  • No custom GitHub action repositories are required.
  • The final URL is deterministic and can be printed into CI logs or comments.
1

Upload the build to the preview directory

Run a plain shell step after your build completes. It writes the generated files into the exact directory Selflify serves for that deploy name.

SITE=marketing
DEPLOY_NAME="pr-${PR_NUMBER}"
OUTPUT_DIR="dist"
TARGET_DIR="/var/www/${SITE}/${DEPLOY_NAME}"

mkdir -p ~/.ssh
printf '%s\n' "$PREVIEW_SSH_PRIVATE_KEY" > ~/.ssh/selflify-preview
chmod 600 ~/.ssh/selflify-preview

rsync -az --delete \
  -e "ssh -i ~/.ssh/selflify-preview -o StrictHostKeyChecking=no" \
  "${OUTPUT_DIR}/" \
  "${PREVIEW_SSH_USER}@${PREVIEW_SERVER_HOST}:${TARGET_DIR}/"

echo "Preview URL: https://${DEPLOY_NAME}.${SITE}.${PREVIEW_BASE_DOMAIN}"