Ployin ployin

Documentation

Ployin is a self-hosted static site host. Upload a zip of HTML/CSS/JS files and get a live URL instantly — from the dashboard or the CLI.

Getting started

  1. Create an account — click Sign up on the login screen, or run ployin signup in the terminal.
  2. Deploy a site — either upload a zip from the dashboard (+ Deploy in the sidebar) or run the CLI command shown below.
  3. Visit the URL — your site is live at https://my-site.ployin.dev (with your project's name) right away. Pages are cached for up to 30 seconds, so a redeploy or rollback can take that long to reach every visitor.
ployin deploy ./my-site --project my-site

Dashboard

The dashboard gives you a visual interface for everything the CLI can do.

Deploying a zip

There are two entry points:

  • Sidebar → + Deploy — opens the deploy modal. Enter a project name and pick a .zip file. If you leave the name blank it is auto-filled from the zip filename.
  • Project Detail → ↑ Deploy new version — same modal, but the project name is locked to the current project. Use this to push a new version to an existing site.

A progress bar shows real upload progress as bytes are transferred. Once the upload completes, the server extracts and stores the files — the bar shows Processing… during this step. The modal closes automatically on success.

If the new content is identical to the active deployment, the server rejects the upload and shows an error. Fix something in your files and re-upload.

Deployment history

Click any project in the sidebar to open its detail view. The history table lists every deployment, newest first, with version number, file count, and deploy time. The active deployment is highlighted.

Restoring a version

Each inactive row has a Restore button. Clicking it makes that version the active deployment — your live site switches to it (visitors see it within 30 seconds, the edge cache time). The history is preserved; no versions are deleted.

Downloading assets

Every deployment row has download buttons:

  • ↓ Web — downloads the web assets (the files that are served publicly) as a zip.
  • ↓ Src — downloads the source code as a zip. Only visible when the deployment was uploaded with a dist/ folder (see dist/ detection).

You can download any historical version, not just the active one.

Tearing down a project

The Tear down button in the project header permanently deletes the project, all its deployment records, and all stored files. This cannot be undone.

AI assistants

Ployin is easy to drive from an AI coding assistant: the CLI has JSON output and needs no prompts, and there is a ready-made guide the assistant can follow — install the CLI, log in, build, deploy and report the URL, roll back, or add a backend.

# installs the CLI and the /ployin skill for Claude Code, Codex,
# Gemini CLI (and Copilot, if you have it)
curl -fsSL https://ployin.dev/install.sh | sh
ployin update                          # later: update the CLI and the skill
# or the skill alone, without the CLI
for d in ~/.claude/skills ~/.agents/skills; do mkdir -p $d/ployin && curl -fsSL https://ployin.dev/skill.md -o $d/ployin/SKILL.md; done

Then ask "deploy this to Ployin" (in Claude Code you can also type /ployin). ChatGPT has no skills folder: upload ployin-skill.zip as a skill in the app, or give it the guide's address. Other assistants can read the same guide at https://ployin.dev/skill.md, or the short overview at https://ployin.dev/llms.txt. A prompt that works anywhere:

Read https://ployin.dev/skill.md, then build this project and deploy it to Ployin
as "my-site". Ask me for my Ployin login if you need it.

CLI

The ployin CLI is a single self-contained binary that packs your directory into a zip and uploads it to the server.

Installation

curl -fsSL https://ployin.dev/install.sh | sh     # Linux and macOS, into ~/.local/bin

On Windows, download ployin-windows-x86_64.exe and rename it to ployin.exe. With Rust installed you can also build it from the repository: cargo install --path cli.

It talks to https://ployin.dev unless told otherwise. For another server, log in with --api-url; the address is saved with your token:

ployin login --api-url https://ployin.dev

--api-url on any command, or the PLOYIN_API_URL environment variable, overrides the saved address.

Scripts and CI

ployin token                         # print your token (keep it secret)
PLOYIN_TOKEN=… ployin deploy dist -p my-site --json
ployin deploy dist -p my-site --dry-run   # list the files without uploading

Every command takes --json: one JSON document on stdout, or {"error": …, "status": …} on stderr with exit code 1. PLOYIN_PASSWORD logs in without a prompt; teardown needs --yes when there is no terminal.

Authentication

ployin signup          # create an account
ployin login           # log in and save your token
ployin whoami          # show the currently logged-in user
ployin logout          # clear the saved token

Credentials are stored in ~/.ployin/config.json.

Deploying

# Deploy the current directory (project name = directory name)
ployin deploy

# Deploy a specific directory
ployin deploy ./dist

# Deploy with an explicit project name
ployin deploy ./dist --project my-site

The CLI zips the directory (excluding node_modules/ and .git/ by default) and uploads it. On success it prints the version number and live URL.

If the content is unchanged from the last deploy, the server rejects it with a 409 identical content error.

Managing projects

ployin list                            # list all your projects
ployin history <project>              # show deployment history

ployin rollback <project>             # roll back to the previous version
ployin rollback <project> --version 2 # roll back to a specific version

ployin teardown <project>             # delete a project (prompts for confirmation)
ployin teardown <project> --yes       # skip confirmation

Downloading

ployin download <project>              # download web assets of the active version
ployin download <project> --source    # download source code instead
ployin download <project> --out ./dir # save to a specific directory
ployin download <project> --version 3 # a specific version

The --source flag only works when the active deployment was uploaded with a dist/ folder.

.ployinignore

Create a .ployinignore file in the root of the directory you are deploying to exclude files from the zip:

*.log
secrets/
*.env

One glob pattern per line; a pattern without a / matches at any depth. node_modules/, .git/ and other dot-files are always excluded (except .well-known/).

How sites are served

Every project is served at a subdomain of the configured base domain:

https://my-site.ployin.dev

Clean URLs

You do not need to link to .html extensions. The server resolves paths in this order:

  1. Exact file match — /about → about
  2. Add .html — /about → about.html
  3. Directory index — /blog/ → blog/index.html

SPA fallback and custom 404

For single-page applications, create a 200.html file at the root of your zip. Ployin serves it for any path that does not match a real file — the same convention used by Surge.sh.

For a custom not-found page, create 404.html. It is served with a 404 status when no file matches and no 200.html is present.

# Typical SPA deploy
cp dist/index.html dist/200.html
ployin deploy ./dist --project my-app

dist/ folder detection

If the root of your zip contains a dist/ directory, Ployin treats the project as a built project:

  • Web assets — only dist/ is served publicly.
  • Source code — everything else (excluding node_modules/, dist/, .git/) is stored separately and is available for download.

This lets you upload the full project directory and have Ployin automatically separate the built output from the source:

my-project/
├── dist/          ← served as the website
│   ├── index.html
│   └── app.js
├── src/           ← stored as source code
├── package.json   ← stored as source code
└── node_modules/  ← always excluded

Without a dist/ folder, the entire zip is treated as web assets and no source code is stored.

Custom domains

A project can also answer at your own domain, such as www.example.com, with HTTPS set up automatically. Open the project in the dashboard and use Custom domains (or ployin domains my-site add www.example.com). Then, at your DNS provider:

  1. Add the TXT record it shows (_ployin.www.example.com = ployin-verify=…). This proves the domain is yours.
  2. Point the domain here: a CNAME record from www.example.com to the target it shows, or, for a bare domain like example.com, an A record to the address it shows.
  3. Press Verify (or ployin domains my-site verify www.example.com). The certificate is usually ready a few minutes later.

If your DNS is on Cloudflare, set the record to DNS only (grey cloud). The site keeps working at its ployin address too.

Limits & rules

  • Uploads up to about 95 MB per deploy; each project keeps its last 30 versions.
  • Up to 20 projects per account and 10 custom domains per project. Need more? Ask the administrator.
  • Hosting follows the terms and acceptable use policy: no phishing, malware, scams or content you have no right to share. Sites that break it are suspended. See something bad? Report it.

Database

Every project comes with its own backend: a REST API for JSON document collections, user accounts, file storage, and realtime change subscriptions. Each project's data lives in its own database, so projects never see each other's users, documents or files. The dashboard Database section and the JavaScript client SDK both talk to it.

Keys

Each project has two keys. Find them on the project's page under Backend, or with ployin keys <project>. Send a key as the apikey header (and as the Authorization: Bearer token when no user is signed in); the key tells Ployin which project's database to use.

Anon key
Public / client-side

Safe to embed in browser code. Access is governed by Row-Level Security (RLS) policies you define. A collection without policies is private: only the service key can read or write it, except that a signed-in user always owns their own pu_<id>_… collections (the SDK's per-user namespace). Add policies to open a collection up, for example to let anyone read posts.

Service key
Admin / server-side only

Bypasses all RLS policies, for that one project. Never put it in a site or a public repository. The dashboard uses it for you when you open the Database pages.

Leaked a key? Replace keys on the project's page (or ployin keys <project> --rotate): the old ones stop working at once.

Limits: each project's database can grow to 100 MB and its files to 1024 MB; past that, writes answer 507.

Connecting

Dashboard: choose the project under Database in the sidebar; the Collections, Users, Storage and Realtime pages then show that project's data.

JavaScript client SDK: install @ployin/sdk from this server (it is not on the npm registry) and create a client with your project's URL and anon key:

npm install https://ployin.dev/sdk/ployin-sdk-0.2.0.tgz   # or: pnpm / yarn / bun add the same URL

No build step? Import it straight from the page: import { createClient } from 'https://ployin.dev/sdk/0.2.0/index.js'. The newest version is listed in /sdk/latest.json.

import { createClient } from '@ployin/sdk'

const db = createClient('https://my-site.ployin.dev', 'your-project-anon-key')

The URL is your site's own address (or https://ployin.dev: the key picks the project either way). The client attaches the signed-in user's token to every request and refreshes the session before it expires. See the SDK README for the full API reference.

Raw HTTP: pass the key on every request:

GET /rest/v1/posts
apikey: your-project-anon-key
Authorization: Bearer your-project-anon-key

Authentication

The auth API is at /auth/v1. Users sign up and sign in to get a short-lived JWT access token and a refresh token.

// Sign up
const { data, error } = await db.auth.signUp({
  email: '[email protected]',
  password: 'password',
})

// Sign in
const { data, error } = await db.auth.signInWithPassword({
  email: '[email protected]',
  password: 'password',
})
// data.session.access_token — use this as the Bearer token
// data.session.refresh_token — stored automatically by the client

// Get current user
const { data } = await db.auth.getUser()

// Update user
await db.auth.updateUser({ password: 'new-password' })

// Sign out
await db.auth.signOut()

The client SDK stores the session in localStorage and schedules automatic token refresh 60 seconds before expiry. The access token lifetime is controlled by the JWT_EXPIRY env var (default: 3600 seconds).

Raw HTTP:

# Sign up
POST /auth/v1/signup
{"email":"[email protected]","password":"password"}

# Sign in
POST /auth/v1/token?grant_type=password
{"email":"[email protected]","password":"password"}

# Refresh
POST /auth/v1/token?grant_type=refresh_token
{"refresh_token":"..."}

# Get user
GET /auth/v1/user
Authorization: Bearer <access_token>

# Sign out
DELETE /auth/v1/token
Authorization: Bearer <access_token>

Collections

The REST API at /rest/v1/:collection maps HTTP methods to CRUD operations. Every document automatically gets id, createdAt, and updatedAt fields.

// Read — with chained filters
const { data, count } = await db
  .from('posts')
  .select('id,title,author')
  .eq('published', true)
  .order('createdAt', { ascending: false })
  .limit(20)
  .offset(0)

// Insert one or many
await db.from('posts').insert({ title: 'Hello', body: '...' })
await db.from('posts').insert([{ title: 'A' }, { title: 'B' }])

// Update matching documents
await db.from('posts').eq('id', '507f...').update({ title: 'Updated' })

// Delete matching documents
await db.from('posts').eq('id', '507f...').delete()

Available filter operators: eq, neq, gt, gte, lt, lte, like, in, is. like follows SQL: % matches any run of characters, _ one character, and the pattern must match the whole value (case-insensitive). Dotted names (meta.score) reach into nested objects.

The Content-Range response header contains the total document count: 0-N/total. The client SDK exposes this as count on the response.

Raw HTTP:

# List with filters
GET /rest/v1/posts?published=eq.true&limit=20&offset=0&order=createdAt.desc

# Insert
POST /rest/v1/posts
Content-Type: application/json
{"title":"Hello","body":"..."}

# Update (filter in query string)
PATCH /rest/v1/posts?id=eq.507f...
{"title":"Updated"}

# Delete
DELETE /rest/v1/posts?id=eq.507f...

To prevent accidental full-collection deletes, a DELETE without any filter returns 400 for non-service-role callers.

Storage

Files are organized into buckets. Buckets can be public (anyone can download and list) or private (a signed-in user is needed). Uploading and deleting always need a signed-in user or the service role key; creating and deleting buckets need the service role key.

// Create a bucket
await db.storage.createBucket('avatars', { public: true })

// Upload a file
const { data, error } = await db.storage
  .from('avatars')
  .upload('user-123/profile.jpg', file)  // file is a File or Blob

// Get a public URL
const { publicUrl } = db.storage
  .from('avatars')
  .getPublicUrl('user-123/profile.jpg')

// List files in a bucket
const { data: files } = await db.storage.from('avatars').list()

// Download
const { data: blob } = await db.storage.from('avatars').download('user-123/profile.jpg')

// Delete
await db.storage.from('avatars').remove(['user-123/profile.jpg'])

Uploaded files are stored on disk at BUCKETS_PATH (default: ./data/buckets). Point this at a persistent volume in production.

Raw HTTP:

# Create bucket
POST /storage/v1/bucket
{"name":"avatars","public":true}

# Upload
POST /storage/v1/object/avatars/profile.jpg
Content-Type: multipart/form-data
[file field: "file"]

# List objects
GET /storage/v1/object/list/avatars

# Download
GET /storage/v1/object/avatars/profile.jpg

# Delete
DELETE /storage/v1/object/avatars/profile.jpg

# Delete bucket
DELETE /storage/v1/bucket/avatars

Realtime

Every write through the REST API is pushed to subscribers over a WebSocket as an INSERT, UPDATE, or DELETE event. A collection's SELECT policies decide which events each subscriber receives.

// db.channel() binds the collection name automatically
const channel = db.channel('posts')

channel.on('*', (payload) => {
  console.log(payload.event)  // 'INSERT' | 'UPDATE' | 'DELETE'
  console.log(payload.new)    // new document
  console.log(payload.old)    // old document (DELETE only)
})

await channel.subscribe()  // no argument needed

// Unsubscribe when done (e.g. on component unmount)
channel.unsubscribe()

Raw WebSocket:

// Connect
const ws = new WebSocket('wss://ployin.dev/realtime/v1')

// Subscribe after open
ws.send(JSON.stringify({
  type: 'subscribe',
  id: 'my-sub',
  collection: 'posts',
}))

// Incoming message shape
{
  type: 'change',
  event: 'INSERT' | 'UPDATE' | 'DELETE',
  collection: 'posts',
  new: { id: '...', ... },   // INSERT and UPDATE
  old: { id: '...' }           // UPDATE and DELETE
}

Domain & production

The database is part of the Ployin server, so it is deployed with it: there is no second process and no external database. For production:

  • Set ANON_KEY and SERVICE_ROLE_KEY to long random values. The server logs a warning while the development defaults are in use.
  • Back up the server's data directory: the SQLite file (ployin-server backup <file> writes a consistent copy) and BUCKETS_PATH.
  • If a reverse proxy sits in front, pass WebSocket upgrades through for /realtime/v1.

Rotate the service role key so only your dashboard and trusted server code can bypass RLS.