Single source of truth
selflify.config.json keeps panel state, site definitions and
operational metadata together.
Filesystem-backed preview control for static SPAs
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
Feature set
Selflify stays intentionally literal: the panel reflects what is on disk, what Caddy serves and which records point traffic at the box.
selflify.config.json keeps panel state, site definitions and
operational metadata together.
Changes validate and reload through the Caddy admin API instead of brute-force restarts.
Set preview login and password in the panel, with
caddy hash-password handled under the hood.
Create sites and keep wildcard DNS aligned with the configured server IP without manual clicks.
Add one extra hostname for the main branch while previews stay on the canonical subdomain and wildcard hosts.
Stable and preview folders under /var/www become a readable deploy
list with size and status.
Revision checks, snapshots and orphan cleanup keep destructive actions disciplined.
Bootstrap
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
Docker, the Compose plugin, project files, .env and the initial
runtime config are placed on the server automatically from the release assets.
Open http://<server-ip>/setup to create the account, enter the
domain, server IP and Caddy email, then paste the Cloudflare token.
The marketing site stays separate, so you can edit it locally and publish
dist/landing to GitHub Pages.
Workflow
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.
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.
Add a site, choose its subdomain and main branch, and let Selflify keep routing, wildcard DNS and placeholder content aligned with that config.
Protect previews with a shared login and password, inspect deploy inventory from disk and remove obsolete preview folders when they are no longer needed.
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
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.
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.
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.
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.
/setup or later into Settings.app.example.dev.*.app.example.dev.Environment
.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
Create .env before running docker compose or
yarn dev. On a bootstrapped server, that means
/opt/selflify by default.
AUTH_SECRET is the critical one. The other values are operational
knobs for config paths, Caddy integration, backup retention, Cloudflare mocking and
cleanup intervals.
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
Selflify supports two practical integration styles: GitHub workflows with reusable
actions, or plain CLI uploads over SSH and rsync.
GitHub
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.
.github/preview-sites.json in the application repository..github/workflows/preview.yml.PREVIEW_BASE_DOMAIN, PREVIEW_SERVER_HOST and
PREVIEW_SSH_PRIVATE_KEY in GitHub Actions secrets.
preview-url for the final hostname and wait for the
deployed commit to become reachable.
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"
}
]
}
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
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
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.
pr-1234 or a commit SHA.PREVIEW_SSH_USER and PREVIEW_SERVER_HOSTPREVIEW_SSH_PRIVATE_KEYPREVIEW_BASE_DOMAIN, SITE and DEPLOY_NAMERun 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}"