Deploy .NET and Node.js apps to VPS servers with zero-downtime deployments.
$ dotnet add package shiphoundDeploy full-stack apps to your VPS in one command.
ShipHound is a free, open-source CLI tool that automates the entire deployment pipeline: build locally, upload via SSH, configure Caddy reverse proxy with auto-HTTPS, set up systemd services, and go live.
Supports .NET and Node.js/Express backends with multi-service deployments. No Docker. No cloud vendor lock-in.
shiphound deploy
shiphound deploy handles everythingshiphound setup installs Caddy, .NET, Node.js as neededdotnet tool install -g shiphound
Verify the installation:
shiphound --help
dotnet tool update -g shiphound
dotnet tool uninstall -g shiphound
cd /path/to/your/project
shiphound init
This creates a shiphound.yaml config file in your project root.
shiphound.yamlproject: my-app
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
frontend:
path: ./frontend
domain: myapp.com
build: npm run build
output: dist
backend:
path: ./backend
port: 5000
domain: api.myapp.com
env:
DATABASE_URL: "postgres://localhost/mydb"
shiphound setup
This automatically installs Caddy, .NET runtime, Node.js, and configures directories and permissions.
shiphound deploy
Your app is now live at https://myapp.com with automatic HTTPS.
| Command | Description |
|---|---|
shiphound init | Create a new shiphound.yaml config file |
shiphound validate | Validate your configuration |
shiphound setup | Set up VPS (installs Caddy, .NET/Node.js as needed) |
shiphound setup --dry-run | Preview what setup would install |
shiphound deploy | Deploy your app to VPS |
shiphound deploy --dry-run | Preview deployment without executing |
shiphound deploy --skip-health-check | Deploy without HTTP health checks |
shiphound status | Check app health and URLs |
shiphound ps | List all deployed apps on VPS |
shiphound logs | View application logs |
shiphound logs -f | Follow logs in real-time |
shiphound logs --service api | View logs for a specific service |
shiphound logs --service caddy | View Caddy access logs |
shiphound history | View deployment history |
shiphound diagnose | Run diagnostic checks on VPS |
shiphound debug | Debug deployment file structure and config |
shiphound rollback | Roll back to a previous deployment |
project: my-portfolio
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
frontend:
path: ./
domain: portfolio.com
build: npm run build
output: dist
env: {}
project: my-api
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
backend:
path: ./
port: 5000
domain: api.myapp.com
env:
DATABASE_URL: "postgres://localhost/db"
project: my-express-api
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
backend:
type: node
path: ./
port: 3000
domain: api.myapp.com
entry: index.js
env:
DATABASE_URL: "postgres://localhost/db"
project: my-ts-api
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
backend:
type: node
path: ./
port: 3000
domain: api.myapp.com
build: "npm run build"
output: dist
entry: index.js
env:
DATABASE_URL: "postgres://localhost/db"
project: my-fullstack
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
frontend:
path: ./frontend
domain: myapp.com
build: npm run build
output: dist
backend:
path: ./backend
port: 5000
domain: api.myapp.com
env:
DATABASE_URL: "postgres://localhost/db"
project: my-fullstack
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
frontend:
path: ./frontend
domain: myapp.com
build: npm run build
output: dist
backend:
type: node
path: ./backend
port: 3000
domain: api.myapp.com
build: "npm run build"
output: dist
entry: index.js
env:
DATABASE_URL: "postgres://localhost/db"
Deploy multiple backend services with separate domains:
project: my-platform
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
frontend:
path: ./frontend
domain: myapp.com
build: npm run build
output: dist
services:
- name: api
path: ./backend
port: 5000
domain: api.myapp.com
type: dotnet
healthCheckPath: /health
- name: notifications
path: ./notification-service
port: 5001
domain: notifications.myapp.com
type: node
entry: index.js
env:
SMTP_HOST: "smtp.example.com"
env:
DATABASE_URL: "postgres://localhost/db"
Multiple services sharing the same domain:
project: my-platform
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
frontend:
path: ./frontend
domain: myapp.com
build: npm run build
output: dist
services:
- name: api
path: ./backend
port: 5000
domain: api.myapp.com
type: dotnet
- name: notifications
path: ./notification-service
port: 5001
domain: api.myapp.com
domainPath: /notifications/*
type: node
entry: index.js
env:
DATABASE_URL: "postgres://localhost/db"
Deploy extra files/folders alongside your app:
project: my-app
vps:
host: 123.45.67.89
user: deploy
sshKey: ~/.ssh/id_rsa
frontend:
path: ./frontend
domain: myapp.com
build: npm run build
output: dist
backend:
type: node
path: ./backend
port: 3000
domain: api.myapp.com
entry: index.js
additionalPaths:
- source: ./templates
destination: templates
exclude:
- node_modules
- .git
- source: ./seed-data.json
destination: data/seed.json
env:
DATABASE_URL: "postgres://localhost/db"
# Project name (used for service names and directories)
project: my-app
# VPS connection settings
vps:
host: 123.45.67.89 # VPS IP or hostname
user: deploy # SSH user with sudo access
sshKey: ~/.ssh/id_rsa # Path to SSH private key
# Runtime versions (installed by 'shiphound setup')
dotnetVersion: "8.0" # .NET runtime version (e.g., "8.0", "9.0")
nodeVersion: "20" # Node.js major version (e.g., "20", "22")
# Frontend configuration (optional)
frontend:
path: ./frontend # Path to frontend directory
domain: myapp.com # Domain for frontend
build: npm run build # Build command
output: dist # Build output directory
healthCheckPath: / # Health check path (default: /)
# Backend configuration (optional, use this OR services, not both)
backend:
type: dotnet # "dotnet" (default) or "node"
path: ./backend # Path to backend directory
port: 5000 # Port your app listens on
domain: api.myapp.com # Domain for backend API
healthCheckPath: /health # Health check endpoint
# Node.js only options:
build: "npm run build" # Build command (optional)
output: dist # Output directory (optional)
entry: index.js # Entry point file (required for Node.js)
# Multi-service configuration (optional, use this OR backend, not both)
services:
- name: api # Unique service name (used in systemd unit name)
type: dotnet # "dotnet" (default) or "node"
path: ./backend # Path to service source
port: 5000 # Port the service listens on
domain: api.myapp.com # Domain (optional, omit for internal-only services)
domainPath: /api/* # Path prefix for shared-domain routing (optional)
healthCheckPath: /health
build: "dotnet publish -c Release -o ./publish" # Custom build command (optional)
entry: index.js # Node.js entry point (required for node type)
output: dist # Build output directory (optional)
env: # Per-service env vars (merged with global, per-service wins)
SERVICE_KEY: "value"
# Additional paths to deploy (optional)
additionalPaths:
- source: ./templates # Local path (file or directory)
destination: templates # Remote path relative to deploy directory
exclude: # Directories to skip (optional)
- node_modules
- .git
# Environment variables (injected into all systemd services)
# Use single quotes for values containing special characters
# Example: ConnectionStrings__Default: 'Host=localhost;Password="secret"'
env:
DATABASE_URL: "postgres://localhost/db"
NODE_ENV: "production"
1. PRE-FLIGHT CHECKS
- Test SSH connection
- Check disk space
- Verify required runtimes (.NET/Node.js)
- Validate DNS records
2. BUILD (Local)
- npm run build (frontend)
- dotnet publish / npm build (each service)
3. BACKUP
- Create timestamped backup of current deployment
4. UPLOAD
- Upload files via SFTP to new deploy directory
- Upload additional paths (with exclude support)
- For Node.js: runs npm install on VPS
5. CONFIGURE
- Generate Caddy config (reverse proxy + HTTPS)
- Generate systemd service per backend service
6. ACTIVATE
- Update current symlink to new deploy
- Restart services
- Verify services stay running (3s stability check)
- Reload Caddy
7. HEALTH CHECK
- Verify each service responds via localhost
- Rollback automatically if any check fails
After deployment, your VPS has this structure:
/var/www/{project}/
├── current/ -> symlink to deploys/{timestamp}
├── deploys/
│ └── 20240115-143052/
│ ├── frontend/
│ ├── api/
│ └── notifications/
├── backups/
│ └── 20240115-140000/
└── .shiphound/
After deployment, ShipHound verifies your app is running by sending HTTP requests to each service via SSH (curl on localhost). A response with status code 200-399 is considered healthy. If health checks fail, ShipHound automatically rolls back to the previous deployment.
By default, ShipHound checks the root path (/). Most APIs don't serve anything at /, which causes a false failure. You should either:
healthCheckPath that returns a 200 response:backend:
path: ./backend
port: 5000
domain: api.myapp.com
healthCheckPath: /health
shiphound deploy --skip-health-check
The service stability check (verifies the process stays running via systemd) always runs regardless of the --skip-health-check flag.
Health check behavior:
To enable passwordless sudo:
ssh your-user@your-vps
echo "$USER ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/$USER
Everything else (Caddy, .NET, Node.js) is installed automatically by shiphound setup.
# Test SSH manually
ssh -i ~/.ssh/id_rsa deploy@your-vps-ip
# Check SSH key permissions (Linux/Mac)
chmod 600 ~/.ssh/id_rsa
# Check service logs
shiphound logs --service api
# Or SSH and check directly
ssh deploy@your-vps
sudo journalctl -u my-app-api -f
# Check Caddy logs
shiphound logs --service caddy
# Verify DNS points to your VPS
nslookup yourdomain.com
ShipHound automatically rolls back on failure. Check the error message and logs:
shiphound diagnose
shiphound logs
shiphound debug
If a service starts but crashes immediately, ShipHound detects this during the stability check and shows the last 15 lines of logs. Common causes:
If your app targets a different runtime version than what's installed on the VPS:
dotnetVersion: "9.0"
nodeVersion: "22"
Then run shiphound setup again to install the correct version. Preflight checks will block deploys if the required version isn't found.
If the stability check passes but health check fails, your app is running but not responding on the checked path. By default ShipHound checks / which many APIs don't serve.
Fix: Set healthCheckPath to an endpoint that returns HTTP 200, or use --skip-health-check.
YAML has special characters that can break parsing. If your env values contain quotes, colons, or special characters, use single quotes:
env:
# BAD - nested double quotes break YAML:
ConnectionStrings__Default: "Host=localhost;Password="secret""
# GOOD - single quotes handle inner quotes:
ConnectionStrings__Default: 'Host=localhost;Password="secret"'
Check the generated service file on your VPS:
cat /etc/systemd/system/{project}-{service}.service
Q: What backend frameworks are supported?
A: .NET and Node.js/Express. Set type: node for Node.js backends.
Q: Can I deploy multiple services?
A: Yes. Use the services: config instead of backend: to define multiple named services, each with their own port, domain, and runtime type.
Q: Do I need Docker? A: No. ShipHound deploys apps natively on Linux using systemd and Caddy.
Q: How does Node.js deployment work?
A: ShipHound uploads your code (excluding node_modules), runs npm install --production on the VPS, and creates a systemd service.
Q: Will my app restart if it crashes?
A: Yes. The systemd service is configured with Restart=always and starts on boot.
Q: What happens if deployment fails? A: ShipHound automatically restores your previous version from the backup directory.
Q: Can services share a domain?
A: Yes. Use domainPath on services to route different URL paths to different services on the same domain.
Q: Can I deploy extra files like templates or seed data?
A: Yes. Use additionalPaths to deploy any extra files or directories alongside your app, with optional exclude patterns.
Q: What VPS providers work with ShipHound? A: Any provider that gives you a Linux VPS with SSH access: DigitalOcean, Hetzner, Linode, Vultr, AWS Lightsail, etc.
Q: Can I use ShipHound with a monorepo?
A: Yes. Point frontend.path and each service path to the correct subdirectory within your repo.
Contributions are welcome! Please open an issue or submit a pull request on GitHub.
git clone https://github.com/CydoEntis/ShipHound.git
cd ShipHound
dotnet build
dotnet test
Deploy with confidence. Ship with ShipHound.