@fabioplunser/epd
v0.5.12
Published
Easy Project Deployer — deploy any project to one or many servers with Docker or PM2, fronted by Traefik.
Maintainers
Readme
epd — Easy Project Deployer
Deploy any project to one server or ten, with zero downtime, from a single command:
epd deployepd 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: processuploads 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 | bashOption 2: Global CLI via Bun or NPM
bun install -g @fabioplunser/epd
# or
npm install -g @fabioplunser/epdOption 3: Run directly without installing (npx / bunx)
npx @fabioplunser/epd deploy
# or
bunx @fabioplunser/epd deployStandalone 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/epdYou 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 thatPoint 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- Builds and tags the image with your git sha (or uploads the project, in process mode).
- Starts the new version on the other slot —
blueandgreenalternate — next to the version that is currently serving. - 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.
- 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: 5Run 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 balancingExtra entrypoints are ports Traefik listens on, declared once and shared by every app on the server:
proxy:
entrypoints:
web: 80
websecure: 443
private: 8443Environment 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 stagingmerges 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.11With 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-1Set 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 --ciFor 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 blueprints 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 reservedContainers 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 dockerThe 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
