deployer
A one-person PaaS on your own VPS. Paste a Git repo URL into the dashboard and it clones,
builds, runs, and serves your app at <name>.yourdomain.com with automatic HTTPS —
no per-project server config, ever.
What it is
deployer is a small self-hosted alternative to Heroku/Vercel that runs on a single VPS. It is deliberately built for one developer and their own repositories — not multi-tenant hosting.
Zero config by default
Most repos need nothing. A Dockerfile is used if present; otherwise the stack is detected and a Dockerfile is generated for you.
Escape hatches everywhere
Override any decision from the dashboard or an optional deploy.yml — without changing how your app is written.
Safe deploys
A new version must pass a health check before the old container is retired. A failed build never touches what is live.
Everything visible
Live build logs, runtime logs, deployment history, and the exact resolved config for every deploy.
Quick start
1. Install on the VPS
curl -fsSL https://raw.githubusercontent.com/haiderlikesrust/deployer/main/install/install.sh \
| sudo bash -s -- --base-domain yourdomain.com --email you@yourdomain.com --ssl-mode letsencrypt-staging
Start with letsencrypt-staging (expect a browser certificate warning) so Let's Encrypt
rate limits can't bite while you shake things out. Re-run with --ssl-mode letsencrypt
once a deploy works. Re-running is safe — it reuses your existing password.
2. Point DNS at the box
| Type | Host | Value |
|---|---|---|
| A | yourdomain.com | your VPS IP |
| A | *.yourdomain.com | your VPS IP |
The wildcard is what makes every future app work without touching DNS again.
3. Deploy something
Open https://deploy.yourdomain.com, sign in with the password the installer printed
(also in /opt/deployer/.env), hit New app, paste a repo URL, and watch the build log.
No domain yet? Install without --base-domain and you get <your-ip>.sslip.io, which needs no DNS at all.
How builds are detected
Detection runs in this order, and the generated Dockerfile is printed at the top of every build log so nothing is a mystery:
- Repo has a
Dockerfile→ it is used as-is. The container port is read fromEXPOSE(override if wrong). package.jsonwith astartscript → Node server onnode:22-slim. Lockfile decides npm/pnpm/yarn;npm run buildruns automatically when abuildscript exists; dev dependencies are pruned.package.jsonwith only a build (Vite, CRA, Astro, Eleventy…) → built, then the output directory is served by nginx.requirements.txt/pyproject.toml→ Python onpython:3.12-slim. A start command is inferred for FastAPI+uvicorn, Flask+gunicorn, and Django+gunicorn.- Bare
index.html→ served directly by nginx with SPA fallback. - Anything else → the deploy fails with a clear message. Commit a Dockerfile and any stack works.
0.0.0.0 and the port in the
PORT environment variable. deployer always injects PORT, and routes traffic there.
Binding localhost or a hardcoded port is the single most common reason a deploy fails its health check.
Workers are exempt — they are never probed over HTTP (see App types).
App types: web, worker, static
Not every repo is a website. Detection decides between three types, and the choice changes what deployer does with the container — whether it gets a hostname, and how it is health-checked.
| Type | Behaviour |
|---|---|
web | Gets a hostname and HTTPS, routed by Traefik. Must listen on 0.0.0.0:$PORT, and is polled over HTTP before traffic switches to it. |
worker | Bots, queue consumers, cron-style loops, trading scripts. No domain, no URL, no port routing, no HTTP health check. The only gate is that the process stays alive: if it is still running with zero restarts ten seconds after start, the deploy is accepted. Logs are the primary signal, and the dashboard shows uptime, restart count, exit code, and whether it was OOM-killed. |
static | Built once (if there is a build step), then the output directory is served by nginx with SPA fallback so client-side routes survive a refresh. |
How the type is decided
Static classification is settled first. Everything that is left is a web app only if there is concrete evidence it serves HTTP; otherwise it runs as a worker. The signal that decided it is printed in the build log and shown on the deployment, so the answer to “why is this a worker?” is always on screen.
| Signal found in the repo | Resolves to |
|---|---|
A committed Dockerfile | web — a hand-written Dockerfile is treated as deliberate intent. Set type: worker yourself if it is a background job. |
Node dependency on a web framework (express, fastify, koa, hono, next, nuxt, remix, @sveltejs/kit, @nestjs/*, socket.io…) | web |
Start script runs a server framework CLI (next, nuxt, nest, vite, serve…) | web |
Source contains http.createServer(), app.listen(), Bun.serve(), Deno.serve()… | web |
Python dependency in requirements.txt or pyproject.toml on fastapi, flask, django, starlette, uvicorn, gunicorn, aiohttp, tornado… | web |
Python source constructs FastAPI(), Flask(), Quart(), web.Application()… | web |
A frontend build tool (vite, react-scripts, @angular/cli, astro, parcel, @11ty/eleventy) with a build script, or a build script and no server entry point | static |
Bare index.html at the build root | static |
None of the above — a package.json or Python project with an entry point (bot.js, worker.py, run.py, index.js…) but no HTTP evidence | worker |
Client libraries never count as evidence of a server: socket.io-client, ws,
axios, node-fetch, undici and friends are explicitly ignored. A
Discord bot that calls an API is a worker, not a web app.
Overriding the guess
The type is a normal config key, so the usual precedence applies: dashboard beats deploy.yml beats auto-detected. Pick the type explicitly in the New app form, change it later in app settings, or commit it:
type: worker
type: web.
A worker that exits or crash-loops within the first ten seconds fails the deploy, with the exit code and the tail of its logs — so a bot that dies on a missing API key is caught immediately instead of restarting forever in the background.
deploy.yml reference
Entirely optional, lives in your repo root. Every key is optional; unknown keys are warned about, not fatal.
type: web # web | worker | static
port: 8080 # port your app listens on inside the container
build: "npm run build"
start: "node dist/server.js"
dockerfile: docker/Dockerfile.prod # force a specific Dockerfile
health: /healthz # readiness path; enables zero-downtime traffic gating
domain: notes.example.com # custom domain for this app
env:
NODE_ENV: production # non-secret defaults (secrets go in the dashboard)
| Key | Meaning |
|---|---|
type | web gets a URL, worker runs with no route or URL, static is built then served by nginx. Omit it and deployer decides — see App types. |
port | Container port. Ignored for static sites (nginx serves on 80 internally) and for workers. |
build / start | Override the auto-detected commands. Ignored when your repo has its own Dockerfile. |
health | Path polled before traffic switches over. Highly recommended for real web apps; ignored for workers. |
domain | Serve on your own hostname instead of <name>.<base-domain>. Ignored for workers. |
env | Non-secret defaults. Dashboard values override these. |
Config precedence
Resolved per key, so you can override one thing without restating everything:
Highest. Fix something right now without a commit.
Committed intent that travels with the repo.
The fallback guess.
Every deployment stores a snapshot of its fully resolved config, and the deployment view labels each value with where it came from — so “why did it pick port 3000?” always has an answer.
★ AI prompt extension
Paste this into your coding prompts (Claude Code, Cursor, ChatGPT…) when starting or modifying a project you intend to host here. It teaches the model the small set of conventions that make an app deploy on the first try — and it covers background workers as well as web apps, so a bot or queue consumer gets the right rules instead of a pointless HTTP server.
## Deployment target: deployer (self-hosted mini-PaaS)
This project is deployed by pasting its Git repo URL into a deployer dashboard.
deployer clones the repo, builds a container image, and runs it. Web apps are
routed behind Traefik with automatic HTTPS; workers just run. Write the code so
this works with no manual server configuration.
FIRST decide which kind of app this is and declare it in `deploy.yml` — do not
leave it to guesswork:
type: web # serves HTTP: site, API, dashboard
type: worker # bot, queue consumer, scheduler, scraper, trading script
type: static # build output only, served by nginx
Then follow section (A) or (B), and always follow (C).
--- (A) WEB APPS ---
1. The server MUST listen on `0.0.0.0` and on the port given by the `PORT`
environment variable, defaulting sensibly if unset.
Node: app.listen(process.env.PORT || 3000, '0.0.0.0')
Python: uvicorn main:app --host 0.0.0.0 --port $PORT
Never bind to localhost/127.0.0.1 — the health check and the proxy will fail.
2. Expose a health endpoint `GET /healthz` that returns HTTP 200 as soon as the
app is genuinely ready to serve. Keep it cheap and dependency-free.
3. In `deploy.yml`: `type: web`, plus `port:` and `health: /healthz`.
--- (B) BACKGROUND WORKERS ---
1. Do NOT start an HTTP server, do not open a port, do not write a health
endpoint. A worker gets no domain, no URL and no HTTP probe — none of that
code would ever be reached.
2. The worker MUST be the container's foreground process and stay alive: an
awaited loop, a blocking consumer, or an in-process scheduler. No
daemonising, no `&`, no `nohup`, no external cron. For periodic work use an
in-process scheduler instead of exiting after one pass — a process that exits
or crash-loops within 10s of starting fails the deploy.
3. Log to stdout/stderr only, never to log files, and keep output unbuffered
(Python: `PYTHONUNBUFFERED=1` or `flush=True`). One line per event: the live
log stream is the only window into a worker.
4. Fail fast and loudly on missing or invalid configuration at startup, with a
message naming the variable — do not retry forever.
5. In `deploy.yml`: `type: worker`. Make sure there is a start command (a
`start` script in `package.json`, or `start:` in `deploy.yml`).
--- (C) BOTH ---
1. ALL configuration comes from environment variables. No config files with
secrets, no hardcoded hosts, ports, URLs, or credentials. Read them with
sensible defaults so the app boots in development unchanged.
2. Commit a `.env.example` in the repo root documenting EVERY variable the app
reads. deployer parses it and holds the deploy behind a form until the
required values are set, so keep it accurate:
- one short `#` comment line above each key saying what it is;
- required keys: leave the value empty or an obvious placeholder;
- optional keys: give a real default and write `# optional` above them;
- group related keys under `# ---- Section ----` headings;
- never put a real secret in it — the file is committed.
3. Handle SIGTERM: stop accepting new work, finish or checkpoint what is in
flight, exit cleanly. Deploys stop the old container with SIGTERM.
4. Be stateless on disk. The container filesystem is replaced on every deploy.
Persist to an external database or an S3-compatible store — never to local
files, and never to in-process memory that matters after a restart.
5. Bind-mounted local paths are unavailable. Everything the app needs at runtime
must be baked into the image or fetched from a service.
Build conventions (pick ONE and be consistent):
- Preferred: no Dockerfile. Ship a standard `package.json` with a `start`
script (and a `build` script if the project needs compiling), or a standard
`requirements.txt`/`pyproject.toml`. deployer generates a clean production
Dockerfile automatically. Commit the lockfile.
- If the stack is unusual, commit a small multi-stage `Dockerfile` with a `CMD`
honouring `$PORT`; web apps also end it with `EXPOSE <port>`. Note that a
committed Dockerfile makes deployer assume a web app, so set `type: worker`
explicitly when it is a background job.
Also produce a `.dockerignore` covering `node_modules`, `.git`, build caches,
and local env files (including `.env`, but NOT `.env.example`).
Frontend-only projects: a plain static build is fine — build output is served by
nginx with SPA fallback, so use relative asset paths and never hardcode an API
origin; read it from an environment variable at build time.
Why each rule exists
| Rule | What breaks without it |
|---|---|
Bind 0.0.0.0:$PORT | The health gate never passes; the deploy is rolled back and the old version stays live. |
| Config from env | You would have to rebuild the image to change a setting, defeating the point. |
Committed .env.example | deployer can't tell you a required value is missing, so the container starts and dies instead of asking. |
/healthz | Traffic can switch to a container that is still starting, causing a visible blip. |
Declared type | Auto-detection is good, but an unusual worker — or any repo with a Dockerfile — can be mistaken for a web app and given a URL it never answers on. |
| Foreground, stdout-logging worker | A backgrounded process makes the container exit immediately and fail the deploy; file logs are invisible in the dashboard. |
| Stateless disk | Uploads and SQLite files silently vanish on the next deploy. |
| SIGTERM handling | In-flight requests or jobs are cut off during the swap. |
| Committed lockfile | Builds stop being reproducible; npm ci fails outright. |
Environment variables
Set them per app under the Environment tab. Values are masked until clicked, and there is a bulk
.env paste box for moving a local file across in one go.
PORTis always injected by deployer and matches the port traffic is routed to — don't fight it.- Dashboard values override
env:entries indeploy.yml, key by key. - Changes apply on the next deploy; the UI tells you when a redeploy is needed.
- Keep real secrets in the dashboard, not in
deploy.yml— that file is committed to your repo. - Commit a
.env.exampleand the tab becomes a generated form instead of a blank editor.
.env.example & needed setup
If your repo documents its configuration in an example env file, deployer reads it during the
resolving stage of every deploy and turns it into a form. You get a checklist of what the app
needs instead of a container that starts and immediately dies on a missing key.
Which file is read
The first of these found at the build root (after Root directory is applied) wins. There is no recursion:
.env.example
.env.sample
.env.template
env.example
.env.dist
Files larger than 256 KB are skipped, and at most 200 variables are read.
What it understands
# ---- Database ----
# Postgres connection string
DATABASE_URL=
# API key for Stripe (required)
STRIPE_KEY=sk_test_xxxx
LOG_LEVEL=info # optional
# FEATURE_FLAGS=beta
| Part of the file | How it is used |
|---|---|
| Comment block directly above a key | Becomes the field's help text. A blank line between the comment and the key breaks the association. |
Inline # comment after a value | Also becomes help text for that key. |
A # ---- Heading ---- style comment | Starts a section; fields below it are grouped under that heading in the form. |
| The value itself | Used as the input's placeholder when it looks like a real default. deployer never fills a value in for you. |
Key name containing SECRET, TOKEN, KEY, PASSWORD, PRIVATE, AUTH, SALT… | The field is masked in the dashboard. |
A commented-out assignment (# FEATURE_FLAGS=beta) | Shown as an available but optional variable. |
Required vs optional
Each key is judged by the first rule that matches:
PORTis always optional — deployer injects it.- A commented-out key is optional.
- The comment says “optional” or “not required” → optional.
- The comment says “required” or “must be set” → required.
- The value is empty or a placeholder (
changeme,<your-key>,xxxx,TODO,your-api-key,${SOMETHING}…) → required. - Anything else — a real-looking default like
LOG_LEVEL=info→ optional, and that value becomes the placeholder.
The bias is deliberate: a wrongly-required variable costs you one form field, a wrongly-optional one costs
you a crashed deploy. Write # optional above a key to be explicit.
When something is missing
If a required key has no value (an empty string counts as no value), the deployment stops at the
resolving stage with the status needs setup. This is not a failure. Nothing was
built, nothing was started, and whatever is currently live keeps serving traffic untouched.
- Fill them in — the Environment tab renders the schema as a grouped form (required first, optionals collapsed, secrets masked). Save, then deploy.
- Deploy anyway — skips the check for this app from then on. Use it when a variable is genuinely meant to be empty, or the example file is out of date. Toggle it back off in the Environment tab.
The schema is cached on the app after every deploy attempt — including the first one that stopped at needs setup — so the form is available even for an app that has never deployed successfully. Delete the example file from your repo and the checklist disappears on the next deploy.
deployer never copies values out of the example file into your app's environment. It is documentation, not a source of defaults — so committing a real secret there still leaks it, exactly as it always did.
Domains & HTTPS
Every app is reachable at <app-name>.<base-domain> immediately. Set a custom domain
in app settings (or domain: in deploy.yml) to serve it somewhere else — point that
hostname's DNS at the VPS first, then deploy.
Certificates are issued automatically by Let's Encrypt over HTTP-01; HTTP redirects to HTTPS. Certificates persist across restarts, so restarting the stack does not re-issue them.
Private repositories
Paste a personal access token (GitHub, GitLab, Gitea — anything over HTTPS) when creating the app, or later in app settings. Read access to the repo is enough.
Tokens are handed to git through GIT_ASKPASS — never on a command line and never embedded in a
remote URL — and known secret values are scrubbed from build and runtime logs. The API never returns a stored
token; the field is write-only.
Monorepos
Set Root directory in the app's settings (for example apps/web) and everything —
detection, deploy.yml lookup, and the build context — happens inside that subfolder.
Deploy the same repo several times with different root directories and names to run the API and the
frontend as separate apps.
Deploys & rollback safety
Each deployment moves through visible stages:
queued → cloning → resolving → building → starting → checking → live
- A deploy can also end at
needs_envduringresolving— required variables are missing, so nothing is built. See .env.example. - Builds run one at a time, so a small VPS is never crushed by two builds at once.
- The new container starts alongside the old one and must pass its health check before the old one is retired.
- A failed clone, build, or health check leaves the running version completely untouched.
- Old images are retained (3 per app by default) so a rollback has something to roll back to.
- Queue a deploy while one is queued and the older queued one is superseded — the newest commit wins.
Operations
Update deployer itself
cd /opt/deployer/src && git pull && cd .. && docker compose build && docker compose up -d
Useful commands
# admin password
grep ADMIN_PASSWORD /opt/deployer/.env
# deployer + proxy logs
cd /opt/deployer && docker compose logs -f deployer
cd /opt/deployer && docker compose logs -f traefik
# what is running
docker ps --filter label=deployer.managed=true
# stop everything (data is preserved)
cd /opt/deployer && docker compose down
Housekeeping
The System page shows disk, Docker status, and container counts, and offers a prune for dangling images and build cache. Old per-app images are trimmed automatically after each successful deploy, and a deploy refuses to start if free disk is critically low.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
| “app did not answer on port N within 60s” | The app isn't listening on 0.0.0.0:$PORT, or the port is wrong. Bind 0.0.0.0 and read PORT; if the port genuinely differs, set it in app settings. |
Build fails on npm ci |
The lockfile isn't committed, or it disagrees with package.json. Commit package-lock.json and keep it in sync. |
| Build is killed with no error | Out of memory — frontend builds are hungry. Add swap to the VPS, or build a smaller bundle. |
| Certificate warning in the browser | You installed with --ssl-mode letsencrypt-staging. Re-run the installer with --ssl-mode letsencrypt. |
| 404 from the proxy | DNS for that hostname isn't pointing at the VPS, or the app isn't live. Check the wildcard A record and the app's status. |
| “could not detect how to build this repo” | No Dockerfile, no package.json, no Python manifest, no index.html at the build root. Commit a Dockerfile, or set the root directory for a monorepo. |
| App says “needs setup” and nothing built | Not a failure — the repo's .env.example lists required variables that are unset. Fill them in on the Environment tab, or use Deploy anyway. |
| My web app has no URL and shows a “worker” chip | No web framework or HTTP listen call was found, so it was classified as a worker. Set type: web in app settings or deploy.yml. The build log states which signal (or lack of one) decided it. |
| “worker is crash-looping (restarted N times…)” | The process exited within ten seconds of starting. Read the log tail in the deployment — usually a missing environment variable or an unhandled startup exception. |
| Uploaded files disappear | Expected: the container filesystem is replaced each deploy. Use external storage. |
Security
- deployer controls the Docker socket, so the dashboard password is effectively root on the VPS. Use a long one.
- Only deploy repositories you control — anyone who can push to them can run code on your box.
- The dashboard sits behind a hashed password with a signed, HttpOnly, SameSite=Strict session cookie, and login is rate-limited.
- App containers never receive the Docker socket and never publish host ports; traffic reaches them only through Traefik.
- Environment values and tokens are stored in a root-owned
0700directory on the VPS.