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.

repo URLclonedetect & buildrunlive + TLS

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

TypeHostValue
Ayourdomain.comyour VPS IP
A*.yourdomain.comyour 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:

  1. Repo has a Dockerfile → it is used as-is. The container port is read from EXPOSE (override if wrong).
  2. package.json with a start script → Node server on node:22-slim. Lockfile decides npm/pnpm/yarn; npm run build runs automatically when a build script exists; dev dependencies are pruned.
  3. package.json with only a build (Vite, CRA, Astro, Eleventy…) → built, then the output directory is served by nginx.
  4. requirements.txt / pyproject.toml → Python on python:3.12-slim. A start command is inferred for FastAPI+uvicorn, Flask+gunicorn, and Django+gunicorn.
  5. Bare index.html → served directly by nginx with SPA fallback.
  6. Anything else → the deploy fails with a clear message. Commit a Dockerfile and any stack works.
The one rule every web app must follow: listen on 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.

TypeBehaviour
webGets 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.
workerBots, 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.
staticBuilt 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 repoResolves to
A committed Dockerfileweb — 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, tornadoweb
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 pointstatic
Bare index.html at the build rootstatic
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 evidenceworker

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
Workers ignore web-only settings. A health path or custom domain set on a worker is dropped with a note in the build log rather than silently pretending to work. The dashboard shows no URL and no “open site” button for a worker — if you expected one, the type is wrong; set 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)
KeyMeaning
typeweb 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.
portContainer port. Ignored for static sites (nginx serves on 80 internally) and for workers.
build / startOverride the auto-detected commands. Ignored when your repo has its own Dockerfile.
healthPath polled before traffic switches over. Highly recommended for real web apps; ignored for workers.
domainServe on your own hostname instead of <name>.<base-domain>. Ignored for workers.
envNon-secret defaults. Dashboard values override these.

Config precedence

Resolved per key, so you can override one thing without restating everything:

dashboard

Highest. Fix something right now without a commit.

beats
deploy.yml

Committed intent that travels with the repo.

beats
auto-detected

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

RuleWhat breaks without it
Bind 0.0.0.0:$PORTThe health gate never passes; the deploy is rolled back and the old version stays live.
Config from envYou would have to rebuild the image to change a setting, defeating the point.
Committed .env.exampledeployer can't tell you a required value is missing, so the container starts and dies instead of asking.
/healthzTraffic can switch to a container that is still starting, causing a visible blip.
Declared typeAuto-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 workerA backgrounded process makes the container exit immediately and fail the deploy; file logs are invisible in the dashboard.
Stateless diskUploads and SQLite files silently vanish on the next deploy.
SIGTERM handlingIn-flight requests or jobs are cut off during the swap.
Committed lockfileBuilds 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.

.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 fileHow it is used
Comment block directly above a keyBecomes the field's help text. A blank line between the comment and the key breaks the association.
Inline # comment after a valueAlso becomes help text for that key.
A # ---- Heading ---- style commentStarts a section; fields below it are grouped under that heading in the form.
The value itselfUsed 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, SALTThe 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:

  1. PORT is always optional — deployer injects it.
  2. A commented-out key is optional.
  3. The comment says “optional” or “not required” → optional.
  4. The comment says “required” or “must be set” → required.
  5. The value is empty or a placeholder (changeme, <your-key>, xxxx, TODO, your-api-key, ${SOMETHING}…) → required.
  6. Anything else — a real-looking default like LOG_LEVEL=infooptional, 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.

The app detail page shows which file was read, which variables are still unset, and each one's description. Two ways forward:
  • 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.

sslip.io mode: handy with no DNS, but sslip.io shares one global Let's Encrypt quota across every user worldwide, so HTTPS there is best-effort and may fall back to a self-signed certificate. Use a real domain for anything you care about.

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:

queuedcloningresolvingbuildingstartingcheckinglive

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

SymptomCause & 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