@pilaniaanand/ndeploy
v0.0.1
Published
Zero-downtime SSH deployment tool for Laravel and Node.js projects — inspired by Deployer
Maintainers
Readme
ndeploy
[!WARNING] Work in Progress: This project is currently in active development. Features and API are subject to change until the
v1.0.0release.
Zero-downtime SSH deployment for Laravel and Node.js — inspired by Deployer, rebuilt for Node.
npx ndeploy deploy production
npx ndeploy rollback staging
npx ndeploy run artisan:migrate production
npx ndeploy releases productionFeatures
| Feature | Details |
| -------------------- | ------------------------------------------------------------------ |
| Zero-downtime | Atomic current → symlink swap — no interrupted requests |
| Strategies | Built-in recipes for Laravel and Node.js |
| Multi-host | Deploy to many servers in series or parallel |
| Config isolation | Separate stages blocks per environment — no shared mutable state |
| AWS provider | Resolve EC2 hosts by tag, ASG, or instance ID at deploy time |
| Hooks | before / after hooks per task |
| Custom tasks | Register any async (ctx) => {} as a named task |
| Rollback | Single command rolls back to the previous release |
| npx-ready | No global install needed |
| SSH-native | Connects over standard SSH — no agents, no remote daemons |
| Dry-run | --dry-run simulates every step without touching the server |
Installation
# Use without installing (recommended for CI)
npx ndeploy deploy production
# Or install globally
npm install -g ndeploy
# Or as a dev dependency in your project
npm install --save-dev ndeployQuick start
# 1. Generate a config in the current directory
npx ndeploy init
# 2. Deploy
npx ndeploy deploy production
# 3. Rollback if something is wrong
npx ndeploy rollback productionCLI Reference
Usage: ndeploy <command> [stage] [options]
Commands:
init Interactively create deploy.config.js
deploy [stage] Deploy to a stage (default: production)
rollback [stage] Roll back to previous release
run <task> [stage] Run a single named task remotely
releases [stage] List releases on remote server
ssh [stage] Open interactive SSH session
Options:
-c, --config <path> Path to config file (default: deploy.config.js)
-t, --task <name> Run only this task (inside deploy)
-H, --host <host> Target a specific host from the stage
--dry-run Simulate without executing
-v, --verbose Show all remote command output
--no-color Disable color output
-V, --version Print version
-h, --help Print helpConfiguration
ndeploy looks for deploy.config.js (ESM default export) in the current directory.
Also supported: deploy.config.cjs, deploy.config.json, deploy.config.yml.
Minimal config
// deploy.config.js
export default {
strategy: 'laravel', // 'laravel' | 'nodejs'
repository: '[email protected]:your-org/app.git',
stages: {
production: {
hosts: [{
host: '10.0.0.1',
user: 'deploy',
deploy_path: '/var/www/app',
}],
},
},
};Full schema
export default {
// ── Required ──────────────────────────────────────────────────────────────
strategy: 'laravel', // or 'nodejs'
repository: '[email protected]:org/repo.git',
// ── Source control ────────────────────────────────────────────────────────
branch: 'main', // default branch; can be overridden per stage
git_depth: 1, // shallow clone depth (0 = full)
// ── Release management ────────────────────────────────────────────────────
keep_releases: 5, // how many old releases to keep
// ── Shared state (persisted across releases) ─────────────────────────────
shared_files: ['.env'],
shared_dirs: ['storage'], // laravel default; ['uploads','logs'] for node
// ── Writable by web server ────────────────────────────────────────────────
writable_dirs: ['storage', 'bootstrap/cache'],
writable_mode: 'chmod', // 'chmod' | 'acl' | 'chown'
http_user: 'www-data', // used when writable_mode = 'acl' | 'chown'
// ── Custom pipeline (optional — strategy provides a sensible default) ─────
pipeline: [
'deploy:lock',
'setup:dirs',
'git:clone',
'deploy:shared',
'deploy:writable',
'composer:install', // laravel
'artisan:migrate', // laravel
'artisan:optimize', // laravel
'deploy:symlink',
'deploy:cleanup',
'deploy:unlock',
],
// ── Deploy all hosts in parallel (default: false = serial) ───────────────
parallel_deployment: false,
// ── Custom tasks ──────────────────────────────────────────────────────────
tasks: {
'slack:notify': async (ctx) => {
await ctx.run(`curl -s -X POST "$SLACK_URL" -d '{"text":"deployed"}' || true`,
{ ignoreError: true });
},
},
// ── Hooks: inject tasks before / after any built-in task ─────────────────
hooks: {
before: { 'deploy:symlink': ['slack:notify'] },
after: { 'deploy:cleanup': ['slack:notify'] },
},
// ── Laravel-specific ──────────────────────────────────────────────────────
php_bin: 'php8.2',
composer_bin: 'composer',
composer_flags: '--no-dev --no-interaction --optimize-autoloader',
build_assets: false, // set true to add npm:build to pipeline
php_fpm_service: 'php8.2-fpm',
// ── Node.js-specific ──────────────────────────────────────────────────────
package_manager: 'npm', // 'npm' | 'yarn' | 'pnpm'
npm_flags: 'ci --omit=dev',
build_command: 'run build', // runs: npm run build
process_manager: 'pm2', // 'pm2' | 'systemd'
pm2_app_name: 'app',
pm2_config: 'ecosystem.config.cjs', // optional
systemd_unit: 'myapp',
health_check_url: 'http://localhost:3000/health',
health_check_retries: 5,
health_check_interval: 3, // seconds
// ── Stages (project isolation) ────────────────────────────────────────────
stages: {
production: {
branch: 'main',
hosts: [
{
label: 'web-1', // friendly name shown in output
host: '10.0.0.1', // IP or hostname
user: 'deploy',
port: 22,
deploy_path: '/var/www/app',
privateKey: '~/.ssh/id_ed25519',
// identityFile, password, passphrase, jumpHost also supported
},
],
},
staging: {
branch: 'develop',
hosts: [{ host: '10.0.1.1', user: 'deploy', deploy_path: '/var/www/app-staging' }],
},
},
};Per-stage & per-host overrides
Every key in the top-level config can be overridden at the stage or host level. Resolution order (lowest → highest precedence):
global config → stage config → host config → CLI flagsDeployment strategies
Laravel
strategy: 'laravel'Default pipeline:
deploy:lock Prevent concurrent deployments
setup:dirs mkdir releases/ shared/
git:clone Clone repository into releases/<timestamp>
deploy:shared Symlink shared/ dirs & files (.env, storage/)
deploy:writable chmod/acl storage/ bootstrap/cache/
composer:install composer install --no-dev --optimize-autoloader
artisan:storage:link php artisan storage:link
artisan:migrate php artisan migrate --force
artisan:optimize php artisan optimize (config+route+view cache)
deploy:symlink Atomic: mv current → new release
artisan:queue:restart php artisan queue:restart
deploy:cleanup Remove releases beyond keep_releases
deploy:unlock Release lockAvailable Laravel tasks:
| Task | Description |
| ------------------------ | ----------------------------- |
| composer:install | Install PHP deps |
| composer:dump-autoload | Regenerate autoloader |
| artisan:migrate | Run migrations --force |
| artisan:seed | Run seeders --force |
| artisan:optimize | Cache config + routes + views |
| artisan:config:cache | Config cache only |
| artisan:route:cache | Route cache only |
| artisan:view:cache | Blade view cache |
| artisan:cache:clear | Clear application cache |
| artisan:queue:restart | Graceful queue restart |
| artisan:storage:link | Public storage symlink |
| artisan:down | Maintenance mode on |
| artisan:up | Maintenance mode off |
| npm:build | npm ci && npm run build |
| php-fpm:reload | service php-fpm reload |
Node.js
strategy: 'nodejs'Default pipeline (PM2):
deploy:lock
setup:dirs
git:clone
deploy:shared Symlink .env, uploads/, logs/
npm:install npm ci --omit=dev (or yarn/pnpm equivalent)
node:build npm run build (only if build_command is set)
deploy:writable
deploy:symlink
pm2:reload Zero-downtime reload via PM2
health:check HTTP check (only if health_check_url is set)
deploy:cleanup
deploy:unlockAvailable Node.js tasks:
| Task | Description |
| ----------------- | --------------------------------------------- |
| npm:install | npm ci --omit=dev |
| yarn:install | yarn install --frozen-lockfile |
| pnpm:install | pnpm install --frozen-lockfile |
| node:build | Run configured build command |
| pm2:start | Start or reload app via PM2 |
| pm2:reload | Zero-downtime reload |
| pm2:stop | Stop PM2 process |
| pm2:delete | Delete PM2 process |
| systemd:reload | systemctl reload (with fallback to restart) |
| systemd:restart | systemctl restart |
| health:check | HTTP check with retries |
| nginx:reload | nginx -t && systemctl reload nginx |
AWS Provider
Dynamically resolve EC2 hosts at deploy time instead of hard-coding IPs.
npm install @aws-sdk/client-ec2 @aws-sdk/client-ssm// deploy.config.js
import { AwsProvider } from 'ndeploy';
const aws = new AwsProvider({
region: 'us-east-1',
// profile: 'myprofile', // AWS CLI profile
// accessKeyId + secretAccessKey work too; defaults to env / instance role
});
// Resolve by Name tag
const webHosts = await aws.byTag('web-production', {
user: 'ec2-user',
deploy_path: '/srv/app',
privateKey: '~/.ssh/ec2.pem',
});
// Resolve by multiple tags
const apiHosts = await aws.byTags(
{ Environment: 'production', Role: 'api' },
{ user: 'ubuntu', deploy_path: '/srv/api' }
);
// Resolve all instances in an Auto Scaling Group
const asgHosts = await aws.byAutoScalingGroup('my-asg-name', {
user: 'ec2-user', deploy_path: '/srv/app',
});
export default {
strategy: 'nodejs',
repository: '[email protected]:org/api.git',
stages: {
production: {
hosts: webHosts, // ← EC2 instances resolved above
},
},
};Custom tasks & hooks
export default {
strategy: 'nodejs',
repository: '…',
tasks: {
// Any async function receiving a Context object
'db:migrate': async (ctx) => {
ctx.set('remote_cwd', ctx.paths.release);
await ctx.run('node scripts/migrate.js');
},
'notify:slack': async (ctx) => {
const msg = `✅ Deployed ${ctx.releaseName} to ${ctx.stage}`;
await ctx.run(
`curl -s -X POST "$SLACK_WEBHOOK" -d '{"text":"${msg}"}' || true`,
{ ignoreError: true }
);
},
},
hooks: {
before: {
'deploy:symlink': ['db:migrate'], // run before the atomic swap
},
after: {
'deploy:cleanup': ['notify:slack'], // run after cleanup
},
},
stages: { production: { hosts: [{ … }] } },
};The ctx Context API
Every task receives a Context instance:
// ── Variable store ────────────────────────────────────────────────────────
ctx.get('key') // reads var → host config → global config
ctx.set('key', value) // sets a runtime variable
ctx.add('key', ...vals) // appends to an array variable
// ── Remote execution ──────────────────────────────────────────────────────
await ctx.run('command') // run; throw on non-zero exit
await ctx.run('command', { ignoreError: true }) // run; ignore failure
await ctx.capture('command') // run; return trimmed stdout
// ── File transfer ─────────────────────────────────────────────────────────
await ctx.upload('/local/path', '/remote/path')
await ctx.download('/remote/path', '/local/path')
// ── Well-known paths ──────────────────────────────────────────────────────
ctx.paths.deploy // /var/www/app
ctx.paths.releases // /var/www/app/releases
ctx.paths.current // /var/www/app/current ← the live symlink
ctx.paths.shared // /var/www/app/shared
ctx.paths.release // /var/www/app/releases/20240315120000 ← this deploy
// ── Metadata ──────────────────────────────────────────────────────────────
ctx.releaseName // '20240315120000'
ctx.stage // 'production'
ctx.host // host config object
ctx.log // Logger instanceRemote directory layout
/var/www/app/
├── current -> releases/20240315120000 ← always points to live release
├── releases/
│ ├── 20240315120000/ ← newest (just deployed)
│ ├── 20240314093000/ ← previous
│ └── 20240313150000/ ← older (removed by cleanup)
└── shared/
├── .env ← persisted across releases
├── storage/ ← laravel: symlinked into each release
└── uploads/ ← nodejs: symlinked into each releaseProgrammatic API
import { Deployer, ConfigLoader, AwsProvider } from 'ndeploy';
const config = await new ConfigLoader().load('./deploy.config.js');
const deployer = new Deployer(config, { verbose: true });
// Deploy
await deployer.deploy('production');
// Rollback
await deployer.rollback('production');
// Run a single task
await deployer.runTask('artisan:migrate', 'production');
// List releases
await deployer.listReleases('production');Tips
Zero-downtime with Nginx
Point your Nginx root at current/public (Laravel) or proxy to the PM2 process. The atomic symlink swap means Nginx keeps serving the old release while the new one is prepared; the cutover happens in microseconds.
# Laravel
root /var/www/app/current/public;
# Node.js (PM2 handles the reload; nginx just proxies)
location / { proxy_pass http://localhost:3000; }SSH key setup
hosts: [{
host: '10.0.0.1',
user: 'deploy',
deploy_path: '/var/www/app',
// Option 1: explicit key file
privateKey: '~/.ssh/id_ed25519',
// Option 2: key content from env var (great for CI)
privateKey: process.env.SSH_PRIVATE_KEY,
// Option 3: password (not recommended)
password: process.env.SSH_PASSWORD,
}]CI/CD (GitHub Actions)
- name: Deploy
run: npx ndeploy deploy production
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}In your config:
hosts: [{
host: '10.0.0.1',
user: 'deploy',
deploy_path: '/var/www/app',
privateKey: process.env.SSH_PRIVATE_KEY,
}]License
MIT
