@fabioplunser/epd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fabio Plünser
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,458 @@
1
+ # epd — Easy Project Deployer
2
+
3
+ Deploy any project to one server or ten, with zero downtime, from a single
4
+ command:
5
+
6
+ ```bash
7
+ epd deploy
8
+ ```
9
+
10
+ epd takes the good ideas from [Kamal](https://kamal-deploy.org) — a config file
11
+ in your repo, SSH as the only requirement, blue/green containers, rollbacks —
12
+ and fixes the two things that get in the way in practice:
13
+
14
+ - **Many sites on one server.** Every app writes its own routing file for a
15
+ Traefik instance shared by the whole machine. A second (or tenth) site on the
16
+ same box is just another `epd.yml`, with its own domains, paths and ports. No
17
+ fighting over port 80, no proxy that only knows about one app.
18
+ - **You do not have to use Docker.** `mode: process` uploads your project,
19
+ installs dependencies on the server and runs your own start command
20
+ (`bun run start`, `node server.js`, anything) under pm2 — with the same
21
+ blue/green switch, health checks and rollbacks. Traefik runs as a plain
22
+ binary, so the server needs nothing but SSH.
23
+
24
+ Multiple machines, load balancing and failover are first-class: replicas are
25
+ balanced by Traefik, and with `cross_host: true` every server's proxy balances
26
+ across every server's replicas, so one machine can serve while another is down.
27
+
28
+ 📖 **For the in-depth user manual and complete configuration reference, see the [Complete Guide](docs/guide.md).**
29
+
30
+ ---
31
+
32
+ ## Install
33
+
34
+ ### Global CLI via NPM or Bun
35
+
36
+ ```bash
37
+ npm install -g @fabioplunser/epd
38
+ # or
39
+ bun install -g @fabioplunser/epd
40
+ ```
41
+
42
+ Once installed, use `epd` anywhere:
43
+ ```bash
44
+ epd --help
45
+ ```
46
+
47
+ ### Run directly without installing (npx / bunx)
48
+
49
+ ```bash
50
+ npx @fabioplunser/epd deploy
51
+ # or
52
+ bunx @fabioplunser/epd deploy
53
+ ```
54
+
55
+ ### Standalone Precompiled Executables (Zero Runtime Dependencies)
56
+
57
+ Download single-file standalone binaries directly from [GitHub Releases](https://github.com/FabioPlunser/epd/releases):
58
+ - `epd-linux-x64` / `epd-linux-arm64`
59
+ - `epd-darwin-x64` / `epd-darwin-arm64` (macOS Intel & Apple Silicon)
60
+ - `epd-windows-x64.exe`
61
+
62
+ ```bash
63
+ curl -fsSL https://github.com/FabioPlunser/epd/releases/latest/download/epd-linux-x64 -o /usr/local/bin/epd
64
+ chmod +x /usr/local/bin/epd
65
+ ```
66
+
67
+ ### From source
68
+
69
+ ```bash
70
+ git clone https://github.com/FabioPlunser/epd.git && cd epd && bun install
71
+ bun run build # compiles dist/cli.js and dist/epd
72
+ ```
73
+
74
+ You need on **your machine**: `bun`, `ssh`, and `docker` (only for `mode: docker`)
75
+ or `rsync` (only for `mode: process`). You need on **the server**: SSH access.
76
+ `epd setup` installs everything else.
77
+
78
+ ---
79
+
80
+ ## Quick start
81
+
82
+ ```bash
83
+ cd my-project
84
+ epd init # writes epd.yml (and a Dockerfile if you want one)
85
+ epd setup # prepares the servers and deploys
86
+ epd deploy # every time after that
87
+ ```
88
+
89
+ Point your domain's A record at the server(s) first — Traefik requests a
90
+ Let's Encrypt certificate the first time a request arrives for the domain.
91
+
92
+ ---
93
+
94
+ ## What a deploy does
95
+
96
+ ```
97
+ build → ship → start new replicas → health check → switch routes → retire old
98
+ ```
99
+
100
+ 1. Builds and tags the image with your git sha (or uploads the project, in
101
+ process mode).
102
+ 2. Starts the new version on the *other* slot — `blue` and `green` alternate —
103
+ next to the version that is currently serving.
104
+ 3. Waits for every new replica to pass its health check. If any fails, the new
105
+ replicas are removed and the old version keeps serving. Nothing is switched.
106
+ 4. Writes the routing file, waits for the proxy to pick it up, then retires the
107
+ old replicas after a drain period.
108
+
109
+ The old version is never stopped before the new one is proven and routed, which
110
+ is what makes the deploy zero-downtime. There is an end-to-end test that hammers
111
+ a site while redeploying it and fails if a single request is not a 200.
112
+
113
+ ---
114
+
115
+ ## Configuration
116
+
117
+ `epd.yml` lives in your project root. Everything below is optional except
118
+ `name`, one server entry, and — in process mode — a start command.
119
+
120
+ ```yaml
121
+ name: blog
122
+ mode: docker # docker (build an image) | process (upload + pm2)
123
+
124
+ image: ghcr.io/me/blog # docker mode only
125
+
126
+ registry: # omit entirely to ship images over SSH instead
127
+ server: ghcr.io
128
+ username: me
129
+ password: ${GITHUB_TOKEN}
130
+
131
+ build:
132
+ dockerfile: Dockerfile
133
+ context: .
134
+ platform: linux/amd64
135
+ args:
136
+ COMMIT: ${GIT_SHA:-dev}
137
+ secrets: # id -> env var, exposed to the build via BuildKit
138
+ npm_token: NPM_TOKEN
139
+
140
+ ssh:
141
+ user: root
142
+ port: 22
143
+ key: ~/.ssh/id_ed25519
144
+ proxy_jump: bastion.example.com
145
+
146
+ servers:
147
+ web:
148
+ hosts: [203.0.113.10, 203.0.113.11]
149
+ replicas: 2 # per host, load balanced by the proxy
150
+ port: 3000 # the port your app listens on
151
+ domains: [example.com, www.example.com]
152
+ env:
153
+ clear:
154
+ LOG_LEVEL: info
155
+ secret:
156
+ - DATABASE_URL
157
+ volumes:
158
+ - /var/lib/epd/volumes/blog-uploads:/app/uploads
159
+ cpus: "2"
160
+ memory: 1g
161
+
162
+ worker: # no routes → not exposed, no HTTP health check
163
+ hosts: [203.0.113.10]
164
+ command: bun run worker.ts
165
+
166
+ proxy:
167
+ ssl: true
168
+ email: me@example.com
169
+ challenge: http # http | dns | tlsalpn
170
+ entrypoints:
171
+ web: 80
172
+ websecure: 443
173
+ cross_host: false
174
+ reload_wait: 3
175
+
176
+ healthcheck:
177
+ path: /up
178
+ status: 200-399
179
+ timeout: 60
180
+ interval: 2
181
+
182
+ env:
183
+ clear:
184
+ NODE_ENV: production
185
+ secret:
186
+ - SECRET_KEY_BASE # read from your shell or .env at deploy time
187
+
188
+ accessories:
189
+ db:
190
+ image: postgres:17
191
+ host: 203.0.113.10
192
+ env:
193
+ clear: { POSTGRES_DB: blog }
194
+ secret: [POSTGRES_PASSWORD]
195
+ volumes: [/var/lib/epd/volumes/blog-db:/var/lib/postgresql/data]
196
+ ports: ["127.0.0.1:5432:5432"]
197
+
198
+ hooks:
199
+ pre_deploy: ./bin/test
200
+ post_deploy: ./bin/notify
201
+
202
+ strategy: rolling # rolling (one host at a time) | parallel
203
+ keep_releases: 5
204
+ ```
205
+
206
+ Run `epd config` to see every default filled in, and `epd config --traefik` to
207
+ see the exact proxy configuration epd will write.
208
+
209
+ ### The short form
210
+
211
+ For a single service you can skip the `servers:` block entirely:
212
+
213
+ ```yaml
214
+ name: site
215
+ hosts: [203.0.113.10]
216
+ port: 3000
217
+ domains: [example.com]
218
+ proxy:
219
+ email: me@example.com
220
+ ```
221
+
222
+ ### Routes: several sites, paths and ports
223
+
224
+ `domains:` is shorthand. `routes:` gives you the rest — this is the part Kamal
225
+ cannot express:
226
+
227
+ ```yaml
228
+ servers:
229
+ web:
230
+ hosts: [203.0.113.10]
231
+ port: 3000
232
+ routes:
233
+ - host: example.com # → :3000
234
+ - host: api.example.com
235
+ port: 8080 # a different port, same container
236
+ - host: example.com
237
+ path: /admin
238
+ port: 4000
239
+ strip_path: true # /admin/x reaches the app as /x
240
+ basic_auth: ["admin:$apr1$…"]
241
+ - host: www.example.com
242
+ redirect: https://example.com
243
+ - host: "*.example.com" # wildcard (needs a DNS challenge)
244
+ priority: 1
245
+ - host: internal.example.com
246
+ entrypoint: private # a non-standard port, see below
247
+ ssl: false
248
+ - host: example.com
249
+ path: /ws
250
+ sticky: true # cookie-pinned load balancing
251
+ ```
252
+
253
+ Extra entrypoints are ports Traefik listens on, declared once and shared by
254
+ every app on the server:
255
+
256
+ ```yaml
257
+ proxy:
258
+ entrypoints:
259
+ web: 80
260
+ websecure: 443
261
+ private: 8443
262
+ ```
263
+
264
+ ### Environment and secrets
265
+
266
+ `env.clear` is written into the config; `env.secret` lists variable *names*
267
+ whose values are read from your shell or a `.env` file at deploy time and
268
+ written to the server as a `0600` file. Secrets never end up in the repo, in a
269
+ `docker inspect`, or in your shell history.
270
+
271
+ `.env`, `.env.local` and `.env.<destination>` next to `epd.yml` are loaded
272
+ automatically. Real environment variables always win.
273
+
274
+ ### Destinations
275
+
276
+ ```bash
277
+ epd deploy -d staging
278
+ ```
279
+
280
+ merges `epd.staging.yml` over `epd.yml` and loads `.env.staging`. Useful for a
281
+ staging fleet, a different domain, or fewer replicas.
282
+
283
+ ---
284
+
285
+ ## Multiple servers, load balancing and failover
286
+
287
+ Each server runs a Traefik instance and its own replicas. Two levels of
288
+ balancing are available:
289
+
290
+ **Per host (default).** Traefik balances across the replicas on its own machine
291
+ and drops any replica that fails its health check. Spread traffic across
292
+ machines with round-robin DNS or a load balancer in front.
293
+
294
+ **Across hosts (`proxy.cross_host: true`).** Every server's proxy balances
295
+ across *every* server's replicas. If one machine's app dies — or the whole
296
+ machine does — the others keep serving its share, so DNS round-robin no longer
297
+ sends a fraction of visitors to a dead box.
298
+
299
+ ```yaml
300
+ proxy:
301
+ cross_host: true
302
+ private_ips: # optional: keep the traffic on a private network
303
+ 203.0.113.10: 10.0.0.10
304
+ 203.0.113.11: 10.0.0.11
305
+ ```
306
+
307
+ With `cross_host` the app port is published on each host (bound to the private
308
+ address when you set one), so replicas can be reached from the other machines.
309
+ Firewall that port to your servers.
310
+
311
+ Deploys roll one host at a time by default, so the fleet keeps serving
312
+ throughout. `strategy: parallel` deploys everywhere at once when you would
313
+ rather have it over with.
314
+
315
+ ### Certificates on several machines
316
+
317
+ Each server gets its own certificate. With the HTTP challenge and round-robin
318
+ DNS, a challenge request can land on a machine that is not the one asking, and
319
+ the first attempt may fail before a retry succeeds. For more than one machine
320
+ use the DNS challenge:
321
+
322
+ ```yaml
323
+ proxy:
324
+ challenge: dns
325
+ dns_provider: cloudflare
326
+ dns_env: [CF_DNS_API_TOKEN]
327
+ ```
328
+
329
+ The listed variables are read from your environment and passed to Traefik.
330
+
331
+ ---
332
+
333
+ ## Without Docker: process mode
334
+
335
+ ```yaml
336
+ name: site
337
+ mode: process
338
+
339
+ process:
340
+ install: bun install --frozen-lockfile
341
+ build: bun run build
342
+ start: bun run start # each replica gets a unique $PORT
343
+ exclude: [".git", "node_modules", ".env"]
344
+ source: rsync # or "git" to upload a clean archive of HEAD
345
+
346
+ servers:
347
+ web:
348
+ hosts: [203.0.113.10]
349
+ replicas: 2
350
+ port: 3000
351
+ domains: [example.com]
352
+ ```
353
+
354
+ `epd setup` installs rsync, Node (pm2 needs it), Bun and pm2, and downloads the
355
+ Traefik binary — the server never needs Docker. Deploys rsync the project into
356
+ `/var/lib/epd/apps/<app>/releases/<version>`, run your install and build
357
+ commands there, start the new replicas under pm2 on the other slot, health check
358
+ them, switch the routes and then delete the old slot.
359
+
360
+ Your start command is run as-is with `PORT`, `EPD_REPLICA`, `EPD_VERSION` and
361
+ your `env` entries set. Two replicas means two processes on two ports, balanced
362
+ by Traefik.
363
+
364
+ Everything else works the same: `epd status`, `epd logs`, `epd rollback`,
365
+ `epd exec`. Accessories are the one exception — they are always containers, so
366
+ they need Docker on their host.
367
+
368
+ ---
369
+
370
+ ## Commands
371
+
372
+ | Command | What it does |
373
+ | --- | --- |
374
+ | `epd init` | Create an `epd.yml` (and a starter Dockerfile) |
375
+ | `epd setup` | Install what the servers need, start the proxy, deploy |
376
+ | `epd deploy` | Build and deploy; `--no-build` to reuse a version |
377
+ | `epd redeploy` | Restart the current version everywhere |
378
+ | `epd rollback [version]` | Back to the previous version; `--list` to see them |
379
+ | `epd status` | Every replica, slot, version, accessories and the proxy state |
380
+ | `epd logs -f` | Logs from every replica, prefixed by name (supports `--accessory`) |
381
+ | `epd exec -- <cmd>` | Run a command with the app's image and environment (supports `--accessory`) |
382
+ | `epd shell` | A shell inside a container or accessory that is serving traffic |
383
+ | `epd proxy status\|reboot\|logs\|routes\|remove` | The shared proxy |
384
+ | `epd config` | The resolved configuration; `--traefik` for the proxy files |
385
+ | `epd docker-command` | Print the exact `docker run` (or pm2) commands epd uses (including accessories) |
386
+ | `epd lock status\|release` | The deploy lock |
387
+ | `epd remove` | Stop this app and delete its files from the servers |
388
+
389
+ Global flags: `-c/--config`, `-d/--destination`, `-v/--verbose`.
390
+
391
+ ### Generating the docker command
392
+
393
+ ```bash
394
+ epd docker-command --service web --slot blue
395
+ ```
396
+
397
+ prints the exact, copy-pasteable `docker run` for each replica plus the proxy
398
+ container — useful to check what epd is doing, to run something by hand, or to
399
+ lift the command into another tool. In process mode it prints the pm2
400
+ equivalent.
401
+
402
+ ---
403
+
404
+ ## How it fits together on a server
405
+
406
+ ```
407
+ /var/lib/epd/
408
+ apps/<app>/ state.json, env files, releases (process mode)
409
+ proxy/
410
+ traefik.yml static config, merged from every app on the host
411
+ dynamic/<app>.yml one routing file per app — this is what makes
412
+ acme/ multiple sites on one machine painless
413
+ ports/ the port block each app reserved
414
+ ```
415
+
416
+ Containers are named `epd-<app>-<service>-<slot>-<replica>` and labelled with
417
+ the app, service, slot and version, so everything epd owns is easy to find and
418
+ nothing else on the machine is touched.
419
+
420
+ ---
421
+
422
+ ## Troubleshooting
423
+
424
+ **`epd status` says a host is unreachable.** epd uses your `ssh` binary and
425
+ config. Check with `ssh -p <port> <user>@<host>` first; add `ssh.key` if you
426
+ have many keys loaded.
427
+
428
+ **The health check times out.** The new replicas started but did not answer.
429
+ `epd logs --service web` shows why. The old version is still serving — nothing
430
+ was switched. Point `healthcheck.path` at something cheap that returns 2xx, or
431
+ set `healthcheck: false` for a service that is not an HTTP server.
432
+
433
+ **A certificate is not issued.** `epd proxy logs` shows the ACME exchange. The
434
+ domain must resolve to the server and port 80 must be reachable. On multiple
435
+ machines, use the DNS challenge.
436
+
437
+ **`ports NNNNN-NNNNN are already reserved`.** Two apps hashed to the same port
438
+ block. Set `port_base:` in one of them to any free multiple of 256.
439
+
440
+ **A deploy was interrupted and now everything is locked.** `epd lock release`.
441
+
442
+ ---
443
+
444
+ ## Tests
445
+
446
+ ```bash
447
+ bun test # config, routing, port allocation, health probes
448
+ ./test/e2e/run.sh # real deploy over ssh: docker mode, two apps, rollback
449
+ ./test/e2e/process.sh # real deploy over ssh: process mode, pm2, no docker
450
+ ```
451
+
452
+ The end-to-end tests build a throwaway "server" container running sshd and
453
+ deploy to it for real — including a zero-downtime check that fails if any
454
+ request drops during a deploy.
455
+
456
+ ## License
457
+
458
+ MIT
package/bin/epd.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const distBundle = join(__dirname, "..", "dist", "cli.js");
8
+
9
+ if (existsSync(distBundle)) {
10
+ await import(distBundle);
11
+ } else {
12
+ await import("../src/cli.ts");
13
+ }