npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fabioplunser/epd

v0.5.12

Published

Easy Project Deployer — deploy any project to one or many servers with Docker or PM2, fronted by Traefik.

Readme

epd — Easy Project Deployer

Deploy any project to one server or ten, with zero downtime, from a single command:

epd deploy

epd takes the good ideas from Kamal — a config file in your repo, SSH as the only requirement, blue/green containers, rollbacks — and fixes the two things that get in the way in practice:

  • Many sites on one server. Every app writes its own routing file for a Traefik instance shared by the whole machine. A second (or tenth) site on the same box is just another epd.yml, with its own domains, paths and ports. No fighting over port 80, no proxy that only knows about one app.
  • You do not have to use Docker. mode: process uploads your project, installs dependencies on the server and runs your own start command (bun run start, node server.js, anything) under pm2 — with the same blue/green switch, health checks and rollbacks. Traefik runs as a plain binary, so the server needs nothing but SSH.

Multiple machines, load balancing and failover are first-class: replicas are balanced by Traefik, and with cross_host: true every server's proxy balances across every server's replicas, so one machine can serve while another is down.

📖 Documentation Site: epd.peppi.dev
📖 User Manual & Full Reference: Complete Guide


Install

Option 1: Zero-Dependency Standalone Installer (Recommended)

Installs the self-contained, pre-compiled standalone binary for Linux (x64 / arm64) or macOS (Apple Silicon / Intel). No Node.js, Bun, or NPM runtime required:

curl -fsSL https://raw.githubusercontent.com/FabioPlunser/epd/main/install.sh | bash

Option 2: Global CLI via Bun or NPM

bun install -g @fabioplunser/epd
# or
npm install -g @fabioplunser/epd

Option 3: Run directly without installing (npx / bunx)

npx @fabioplunser/epd deploy
# or
bunx @fabioplunser/epd deploy

Standalone binaries are also downloadable directly from GitHub Releases.

From source

git clone https://github.com/FabioPlunser/epd.git && cd epd && bun install
bun run build     # compiles dist/cli.js and dist/epd

You need on your machine: bun, ssh, and docker (only for mode: docker) or rsync (only for mode: process). You need on the server: SSH access. epd setup installs everything else.


Quick start

cd my-project
epd init          # writes epd.yml (and a Dockerfile if you want one)
epd setup         # prepares the servers and deploys
epd deploy        # every time after that

Point your domain's A record at the server(s) first — Traefik requests a Let's Encrypt certificate the first time a request arrives for the domain.


What a deploy does

build  →  ship  →  start new replicas  →  health check  →  switch routes  →  retire old
  1. Builds and tags the image with your git sha (or uploads the project, in process mode).
  2. Starts the new version on the other slot — blue and green alternate — next to the version that is currently serving.
  3. Waits for every new replica to pass its health check. If any fails, the new replicas are removed and the old version keeps serving. Nothing is switched.
  4. Writes the routing file, waits for the proxy to pick it up, then retires the old replicas after a drain period.

The old version is never stopped before the new one is proven and routed, which is what makes the deploy zero-downtime. There is an end-to-end test that hammers a site while redeploying it and fails if a single request is not a 200.


Configuration

epd.yml lives in your project root. Everything below is optional except name, one server entry, and — in process mode — a start command.

name: blog
mode: docker            # docker (build an image) | process (upload + pm2)

image: ghcr.io/me/blog  # docker mode only

registry:               # omit entirely to ship images over SSH instead
  server: ghcr.io
  username: me
  password: ${GITHUB_TOKEN}

build:
  dockerfile: Dockerfile
  context: .
  platform: linux/amd64
  args:
    COMMIT: ${GIT_SHA:-dev}
  secrets:              # id -> env var, exposed to the build via BuildKit
    npm_token: NPM_TOKEN

ssh:
  user: root           # default login; a non-root user needs passwordless sudo
  port: 22
  key: ~/.ssh/id_ed25519
  proxy_jump: bastion.example.com

servers:
  web:
    # "user@host" overrides ssh.user for that one server.
    hosts: [203.0.113.10, [email protected]]
    replicas: 2         # per host, load balanced by the proxy
    port: 3000          # the port your app listens on
    domains: [example.com, www.example.com]
    env:
      clear:
        LOG_LEVEL: info
      secret:
        - DATABASE_URL
    volumes:
      - /var/lib/epd/volumes/blog-uploads:/app/uploads
    cpus: "2"
    memory: 1g

  worker:               # no routes → not exposed, no HTTP health check
    hosts: [203.0.113.10]
    command: bun run worker.ts

proxy:
  ssl: true
  email: [email protected]
  challenge: http       # http | dns | tlsalpn
  entrypoints:
    web: 80
    websecure: 443
  cross_host: false
  reload_wait: 3

healthcheck:
  path: /up
  status: 200-399
  timeout: 60
  interval: 2

env:
  clear:
    NODE_ENV: production
  secret:
    - SECRET_KEY_BASE   # read from your shell or .env at deploy time

accessories:
  db:
    image: postgres:17
    host: 203.0.113.10
    env:
      clear: { POSTGRES_DB: blog }
      secret: [POSTGRES_PASSWORD]
    volumes: [/var/lib/epd/volumes/blog-db:/var/lib/postgresql/data]
    ports: ["127.0.0.1:5432:5432"]

hooks:
  pre_deploy: ./bin/test
  post_deploy: ./bin/notify

strategy: rolling       # rolling (one host at a time) | parallel
keep_releases: 5

Run epd config to see every default filled in, and epd config --traefik to see the exact proxy configuration epd will write.

The short form

For a single service you can skip the servers: block entirely:

name: site
hosts: [203.0.113.10]
port: 3000
domains: [example.com]
proxy:
  email: [email protected]

Routes: several sites, paths and ports

domains: is shorthand. routes: gives you the rest — this is the part Kamal cannot express:

servers:
  web:
    hosts: [203.0.113.10]
    port: 3000
    routes:
      - host: example.com                 # → :3000
      - host: api.example.com
        port: 8080                        # a different port, same container
      - host: example.com
        path: /admin
        port: 4000
        strip_path: true                  # /admin/x reaches the app as /x
        basic_auth: ["admin:$apr1$…"]
      - host: www.example.com
        redirect: https://example.com
      - host: "*.example.com"             # wildcard (needs a DNS challenge)
        priority: 1
      - host: internal.example.com
        entrypoint: private               # a non-standard port, see below
        ssl: false
      - host: example.com
        path: /ws
        sticky: true                      # cookie-pinned load balancing

Extra entrypoints are ports Traefik listens on, declared once and shared by every app on the server:

proxy:
  entrypoints:
    web: 80
    websecure: 443
    private: 8443

Environment and secrets

env.clear is written into the config; env.secret lists variable names whose values are read from your shell or a .env file at deploy time and written to the server as a 0600 file. Secrets never end up in the repo, in a docker inspect, or in your shell history.

.env, .env.local and .env.<destination> next to epd.yml are loaded automatically. Real environment variables always win.

Destinations

epd deploy -d staging

merges epd.staging.yml over epd.yml and loads .env.staging. Useful for a staging fleet, a different domain, or fewer replicas.


Multiple servers, load balancing and failover

Each server runs a Traefik instance and its own replicas. Two levels of balancing are available:

Per host (default). Traefik balances across the replicas on its own machine and drops any replica that fails its health check. Spread traffic across machines with round-robin DNS or a load balancer in front.

Across hosts (proxy.cross_host: true). Every server's proxy balances across every server's replicas. If one machine's app dies — or the whole machine does — the others keep serving its share, so DNS round-robin no longer sends a fraction of visitors to a dead box.

proxy:
  cross_host: true
  private_ips:            # optional: keep the traffic on a private network
    203.0.113.10: 10.0.0.10
    203.0.113.11: 10.0.0.11

With cross_host the app port is published on each host (bound to the private address when you set one), so replicas can be reached from the other machines. Firewall that port to your servers.

Deploys roll one host at a time by default, so the fleet keeps serving throughout. strategy: parallel deploys everywhere at once when you would rather have it over with.

Certificates on several machines

Each server gets its own certificate. With the HTTP challenge and round-robin DNS, a challenge request can land on a machine that is not the one asking, and the first attempt may fail before a retry succeeds. For more than one machine use the DNS challenge:

proxy:
  challenge: dns
  dns_provider: cloudflare
  dns_env: [CF_DNS_API_TOKEN]

The listed variables are read from your environment and passed to Traefik.

Multi-Provider DNS Automation (Cloudflare & Hetzner DNS)

epd can automatically manage DNS A and AAAA records for your application domains, pointing them to all configured servers with automatic record synchronization during setup and deploy. Both Cloudflare and Hetzner DNS are supported out of the box:

# Unified DNS configuration
dns:
  provider: cloudflare # or hetzner
  api_token: ${DNS_API_TOKEN}
  # zone_id is optional; auto-discovered from your domain name
  proxied: true        # Cloudflare CDN/proxy (default: true)
  ttl: 300             # Hetzner default: 300; Cloudflare: 1 (auto)
  auto_sync: true      # automatically sync records on setup and deploy

(For backward compatibility, the top-level cloudflare: block is also supported).

Manage or preview DNS changes with the CLI:

epd dns status                 # Compare current DNS records vs server IPs
epd dns sync                   # Sync A/AAAA records across all routed domains
epd dns sync --dry-run         # Preview changes without modifying DNS
epd dns sync --no-proxied      # Disable CDN proxying (DNS-only)

Hetzner Cloud Integration & Dynamic Provisioning

epd natively integrates with Hetzner Cloud (hcloud) to list, provision, and deploy directly to Hetzner servers without manually copying IP addresses.

1. Managing Servers with the CLI

# List all Hetzner servers with their status, public IPs, and locations
epd hetzner list

# Inspect available server types, vCPUs, RAM, and pricing
epd hetzner types

# Create and provision a new Hetzner server (default: cx22 in fsn1 with ubuntu-24.04)
epd hetzner create prod-web-1 --type cx22 --location fsn1

# Create and automatically prepare the server with Docker & Traefik:
epd hetzner create prod-web-1 --setup

# Delete a server
epd hetzner delete prod-web-1

Set HETZNER_API_TOKEN or HCLOUD_TOKEN in your environment (or hetzner.api_token in epd.yml).

2. Dynamic Server Targets in epd.yml

Instead of hardcoding IP addresses, deploy to Hetzner servers by name or label selector:

hetzner:
  api_token: ${HETZNER_API_TOKEN}

servers:
  web:
    # Resolve servers by name or label selector:
    hosts:
      - hetzner:prod-web-1
      - hetzner:label:app=web,env=prod
    port: 3000
    domains: [example.com]

When running epd setup or epd deploy, epd queries the Hetzner Cloud API in real time to resolve matching servers to their public IP addresses.


Without Docker: process mode

name: site
mode: process

process:
  install: bun install --frozen-lockfile
  build: bun run build
  start: bun run start        # each replica gets a unique $PORT
  exclude: [".git", "node_modules", ".env"]
  source: rsync               # or "git" to upload a clean archive of HEAD

servers:
  web:
    hosts: [203.0.113.10]
    replicas: 2
    port: 3000
    domains: [example.com]

epd setup installs rsync, Node (pm2 needs it), Bun and pm2, and downloads the Traefik binary — the server never needs Docker. Deploys rsync the project into /var/lib/epd/apps/<app>/releases/<version>, run your install and build commands there, start the new replicas under pm2 on the other slot, health check them, switch the routes and then delete the old slot.

Your start command is run as-is with PORT, EPD_REPLICA, EPD_VERSION and your env entries set. Two replicas means two processes on two ports, balanced by Traefik.

Everything else works the same: epd status, epd logs, epd rollback, epd exec. Accessories are the one exception — they are always containers, so they need Docker on their host.


Automated Deployments with GitHub Actions (CI/CD)

epd can be run directly inside GitHub Actions on push to main. Because your entire deployment configuration (services, replicas, ports, health probes, zero-downtime blue/green slots, accessories, domains, and DNS sync) lives in epd.yml, you only need a single minimal step in GitHub Actions:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure SSH Key
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Deploy with EPD
        run: npx @fabioplunser/epd deploy
        env:
          # Optional DNS and Cloud Provider Tokens
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          HETZNER_API_TOKEN: ${{ secrets.HETZNER_API_TOKEN }}
          HETZNER_DNS_API_TOKEN: ${{ secrets.HETZNER_DNS_API_TOKEN }}
          # App secrets referenced in epd.yml
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

Generate this workflow file automatically when creating your project:

epd init --ci

For detailed guides on setting up SSH deploy keys, using GitHub Container Registry (ghcr.io), or multi-environment deployments, see the GitHub Actions CI/CD Guide.


Commands

| Command | What it does | | --- | --- | | epd init | Create an epd.yml (and a starter Dockerfile) | | epd setup | Install what the servers need, start the proxy, deploy | | epd deploy | Build and deploy; --no-build to reuse a version | | epd redeploy | Restart the current version everywhere | | epd rollback [version] | Back to the previous version; --list to see them | | epd status | Every replica, slot, version, accessories and the proxy state | | epd logs -f | Logs from every replica, prefixed by name (supports --accessory) | | epd exec -- <cmd> | Run a command with the app's image and environment (supports --accessory) | | epd shell | A shell inside a container or accessory that is serving traffic | | epd proxy status\|reboot\|logs\|routes\|remove | The shared proxy | | epd config | The resolved configuration; --traefik for the proxy files | | epd docker-command | Print the exact docker run (or pm2) commands epd uses (including accessories) | | epd lock status\|release | The deploy lock | | epd dns [sync\|status] | Unified DNS automation (Cloudflare & Hetzner DNS) | | epd hetzner list\|create\|delete | Manage and provision Hetzner Cloud servers | | epd update [--check] | Check for updates and upgrade epd to the latest version | | epd cloudflare dns [sync\|status] | Cloudflare DNS record management (alias for epd dns) | | epd remove | Stop this app and delete its files from the servers |

Global flags: -c/--config, -d/--destination, -v/--verbose.

Generating the docker command

epd docker-command --service web --slot blue

prints the exact, copy-pasteable docker run for each replica plus the proxy container — useful to check what epd is doing, to run something by hand, or to lift the command into another tool. In process mode it prints the pm2 equivalent.


How it fits together on a server

/var/lib/epd/
  apps/<app>/            state.json, env files, releases (process mode)
  proxy/
    traefik.yml          static config, merged from every app on the host
    dynamic/<app>.yml    one routing file per app — this is what makes
    acme/                multiple sites on one machine painless
  ports/                 the port block each app reserved

Containers are named epd-<app>-<service>-<slot>-<replica> and labelled with the app, service, slot and version, so everything epd owns is easy to find and nothing else on the machine is touched.


Troubleshooting

epd status says a host is unreachable. epd uses your ssh binary and config. Check with ssh -p <port> <user>@<host> first; add ssh.key if you have many keys loaded.

The health check times out. The new replicas started but did not answer. epd logs --service web shows why. The old version is still serving — nothing was switched. Point healthcheck.path at something cheap that returns 2xx, or set healthcheck: false for a service that is not an HTTP server.

A certificate is not issued. epd proxy logs shows the ACME exchange. The domain must resolve to the server and port 80 must be reachable. On multiple machines, use the DNS challenge.

ports NNNNN-NNNNN are already reserved. Two apps hashed to the same port block. Set port_base: in one of them to any free multiple of 256.

A deploy was interrupted and now everything is locked. epd lock release.


Tests

bun test                  # config, routing, port allocation, health probes
./test/e2e/run.sh         # real deploy over ssh: docker mode, two apps, rollback
./test/e2e/process.sh     # real deploy over ssh: process mode, pm2, no docker

The end-to-end tests build a throwaway "server" container running sshd and deploy to it for real — including a zero-downtime check that fails if any request drops during a deploy.

License

MIT