vmdrop
v0.3.8
Published
A CLI tool for deploying to Linux VMs with multi-distro support.
Readme
VM Drop (vmdrop)
Simple CLI to deploy any Node.js or Bun project to any Linux VM. It provisions the machine (Bun, Caddy, firewall, systemd), uploads your app via rsync, writes env vars, and keeps your service running under systemd with optional HTTPS via Caddy.
Why vmdrop
- Minimal, zero-frills VM deploys using your own server
- One config file, one command
- Works with password or SSH key auth
- First-class Bun support, Node.js compatible
- Multi-distro support: Ubuntu/Debian, Amazon Linux, Rocky Linux, AlmaLinux, CentOS, Alpine, and more
- Auto-detects package manager (apt, dnf, yum, apk)
Requirements
Local (your laptop):
- Bun in PATH (
bun --version) sshandrsyncsshpassif using password auth (macOS:brew install hudochenkov/sshpass/sshpass)
Remote (your VM):
- Any modern Linux distribution (Ubuntu, Debian, Amazon Linux 2023, Rocky Linux, AlmaLinux, CentOS, Alpine, etc.)
- Root or a sudo-capable user for initial provisioning
- systemd for service management
Run (no install)
Prefer running via bunx/npx directly in your project:
# Using Bun (recommended)
bunx vmdrop bootstrap
# Using Node/npm
npx vmdrop bootstrapOptional global install (not required):
npm i -g vmdrop # or: bun add -g vmdropFrom this repo (dev mode):
bun run build
# then run from this repo directory
bun run src/cli/index.ts bootstrap --config ./vmdrop.example.yamlQuickstart: deploy your project
Need help? Run bunx vmdrop --help to see all commands and options.
- In your app repo, create
vmdrop.yaml(see the reference below). Minimal example:
droplet:
host: your.server.or.ip
user: root
app:
name: myapp
dir: /opt/myapp
user: app
runtime:
port: 3000
service:
name: myapp
# For Node apps, set your start command (systemd ExecStart):
execStart: /usr/bin/node dist/server.js
https:
domain: yourdomain.com
email: [email protected]- Optional: create a local
.envalongsidevmdrop.yamlto provide secrets or passwords used in the config (the CLI reads it automatically):
SSH_PASSWORD=your_root_password
MY_SECRET=valueYou can reference env values in vmdrop.yaml with ${VAR}:
ssh:
usePassword: true
password: ${SSH_PASSWORD}
runtime:
env:
MY_SECRET: ${MY_SECRET}- Run bootstrap (provision + deploy + start):
bunx vmdrop bootstrap
# or: npx vmdrop bootstrapAfter DNS propagates, verify your app: https://yourdomain.com/healthz (or your app’s path).
Commands
Use with bunx vmdrop ... (or npx vmdrop ...):
bunx vmdrop bootstrap— Provision the VM, upload the project, write.env, install deps, start/restart systemd service.bunx vmdrop provision— Provision only (Bun, Caddy, firewall, systemd unit). Does not deploy code.bunx vmdrop deploy— Rsync project, update remote.env, install deps, restart service, reload Caddy.bunx vmdrop logs [--lines N]— Tail service logs fromjournalctl.bunx vmdrop ssh— Open an interactive SSH session to the VM.
Global flags:
--help,-h,-?— Show help message with usage information--config <path>— Use a non-default config path (default:vmdrop.yamlorvmdrop.yml)--verboseor-v— Enable verbose logging to see detailed progress information--lines <N>— Number of log lines to show (forlogscommand, default: 200)
Verbose Mode
For troubleshooting or to see exactly what vmdrop is doing, use the --verbose or -v flag:
bunx vmdrop deploy --verbose
# or
bunx vmdrop bootstrap -vVerbose mode shows:
- Local dependency checks
- SSH connection details
- File sync operations with exclusions
- Environment variable counts
- Service existence checks
- Package installation progress
- All commands being executed
Example output:
🔍 Verbose mode enabled
[verbose] Command: deploy
[verbose] Config path: auto-detect
[verbose] Loaded config for myapp
[verbose] Target: [email protected]
[verbose] Checking local dependencies...
[verbose] ✓ Found ssh
[verbose] ✓ Found rsync
📦 Uploading project files...
[verbose] Syncing to [email protected]:/opt/myapp/
[verbose] Excluding: .git, node_modules, .github, bun.lockb
...Deployment Flow
vmdrop bootstrap (first-time deployment)
- Connect - Establish SSH connection to the VM
- Detect - Auto-detect package manager (apt/dnf/yum/apk)
- Provision - Install system packages, Bun, Caddy, configure firewall (UFW or firewalld)
- Upload - Rsync project files to
app.dir - Configure - Create/update remote
.envfile fromruntime.env - Service - Create systemd unit file and enable service
- Start - Install dependencies (if package.json exists), start service
- HTTPS - Caddy automatically requests SSL certificate (if
https:configured)
vmdrop deploy (subsequent updates)
- Upload - Rsync changed files to remote
- Configure - Update remote
.env(merges with existing values) - Restart - Install dependencies, restart systemd service
- Reload - Reload Caddy if Caddyfile changed
vmdrop provision (infrastructure only)
- System - Install packages, Bun, Caddy, firewall
- Service - Create systemd unit file
- Firewall - Configure firewall rules
- (Does not deploy code or start service)
Use from package.json scripts
Add convenient scripts in your app:
{
"scripts": {
"deploy:bootstrap": "bunx vmdrop bootstrap",
"deploy:provision": "bunx vmdrop provision",
"deploy": "bunx vmdrop deploy",
"deploy:logs": "bunx vmdrop logs --lines 300",
"deploy:ssh": "bunx vmdrop ssh"
}
}Then run e.g.: npm run deploy or bun run deploy.
Config reference (vmdrop.yaml)
droplet:
host: 203.0.113.10
user: root # or a sudo-capable user
ssh:
usePassword: false # true enables sshpass
password: ${SSH_PASSWORD} # optional, when usePassword: true
privateKey: ~/.ssh/id_ed25519
app:
name: myapp
dir: /opt/myapp
user: app
runtime:
host: 127.0.0.1 # binds your app behind Caddy
port: 3000
nodeEnv: production
node: false # false (skip), true (LTS v22), or 18/20/22 (specific version)
env: # merged into remote .env; config wins on conflicts
MY_SECRET: ${MY_SECRET}
service:
name: myapp
# Default runs Bun TS entry:
# /usr/local/bin/bun run src/server.ts
# For Node apps, set a Node start command:
execStart: /usr/bin/node dist/server.js
# Systemd service options (optional, with defaults shown):
restart: always # no, always, on-success, on-failure, on-abnormal, on-abort, on-watchdog
restartSec: 2 # seconds to wait before restart
environmentFile: /opt/myapp/.env # defaults to ${app.dir}/.env
killSignal: SIGINT # signal to send on stop
https: # optional; omit to expose plain HTTP on :80 through Caddy
domain: example.com
email: [email protected]
deploy:
path: /opt/myapp # defaults to app.dir
excludes: # defaults: .git, node_modules, .github, bun.lockb
- .git
- node_modules
postInstall: bun run build # optional; runs after bun install (e.g. build step)
# Package management (OS-agnostic, auto-detects apt/dnf/yum/apk)
packages:
manager: auto # auto (default), apt, dnf, yum, or apk
list:
- ffmpeg # extra packages to install during provision
postScript: | # optional bash commands to run at end of provisioning
npm install -g some-tool
echo "custom setup done"
# Backward compatible (deprecated, use 'packages:' instead)
apt:
packages:
- ffmpegNotes:
- Multi-distro support: vmdrop auto-detects your package manager (apt, dnf, yum, apk) and works on Ubuntu, Debian, Amazon Linux, Rocky Linux, AlmaLinux, CentOS, Alpine, and more.
- On provision, the CLI installs base packages (curl, ca-certificates, rsync, unzip) plus your extras, Bun, Caddy, configures firewall (UFW or firewalld), sets up systemd, and writes
/etc/caddy/Caddyfile. - Node.js: set
runtime.node: trueto install Node.js LTS (v22), or specify a major version like18,20,22. Omit or setfalseto skip. Uses NodeSource on apt/dnf/yum; distro packages on Alpine. - postScript: arbitrary bash commands appended at the end of provisioning — useful for installing global tools (
npm install -g ...), firewall tweaks, or custom setup. - Remote
.envis merged: existing values are preserved unless overridden byruntime.env. - Caddy Caddyfile is auto-generated from
https.domainconfig and set up as a reverse proxy to your app. - Strict host key checking is disabled during automation for convenience.
CI/CD
Use bunx/npx in CI. Set repository secrets and call the CLI from a workflow.
Recommended secrets:
DEPLOY_HOST,DEPLOY_USER,DEPLOY_PATH,SERVICE_NAME,SSH_PRIVATE_KEY
Example GitHub Actions job:
name: Deploy
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Deploy
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
SERVICE_NAME: ${{ secrets.SERVICE_NAME }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
bunx vmdrop deployUsing the included example server (optional)
This repo includes a minimal Bun + TypeScript server you can run locally:
bun install
bun run dev
# http://localhost:3000/healthzWebSocket quick test:
const ws = new WebSocket('ws://localhost:3000/ws');
ws.onmessage = (e) => console.log('msg', e.data);
ws.onopen = () => ws.send(JSON.stringify({ type: 'echo', payload: 'hi' }));Troubleshooting
- sshpass not found: install it locally (macOS:
brew install hudochenkov/sshpass/sshpass). - Permission denied (publickey/password): verify SSH credentials in
vmdrop.yamland/or.env. - Caddy TLS fails: ensure your domain’s A/AAAA records point to the droplet’s IP and retry.
