---
title: "Quick start"
description: "Everything you need to go from an empty Ubuntu VPS to a deployed Node.js app, in about ten minutes."
---
This walkthrough covers the **full happy path** â provisioning a fresh server, generating a config, deploying your first release, rolling back, and wiring CI. Skip ahead if a section doesn't apply.
:::tip[Use the ShipNode AI skill in Claude Code]
Inside [Claude Code](https://claude.com/claude-code), the `shipnode` skill knows this CLI end-to-end â deploying, rolling back, configuring Caddy/PM2, managing `.env`, and reading server state. Type `/shipnode` in Claude Code or just describe what you want; the skill is auto-triggered when relevant. Every page on this site also has **Copy as Markdown / Open in Claude / Open in ChatGPT** buttons at the top so you can hand the docs to any AI assistant.
:::
## 0. Prerequisites
| You need | Why |
|---|---|
| **A VPS** running Ubuntu 22.04+ or Debian 12+ | ShipNode provisions Node, PM2, Caddy on this OS. |
| **SSH access as `root`** with your public key in `/root/.ssh/authorized_keys` | The simplest path â what most fresh VPS images give you. |
| **A domain name** with an **A record pointing at the VPS public IP** | Required. Caddy uses the domain to issue an HTTPS cert automatically â no domain, no TLS. |
| **Node.js 18+ locally** | The CLI is a Node.js package. |
### Point your domain at the server
Before anything else, log into your DNS provider (Cloudflare, Namecheap, Route 53, OVHâ¦) and create an **A record**:
| Type | Name | Value | TTL |
|---|---|---|---|
| `A` | `api` *(or `@` for the apex)* | `203.0.113.10` *(your VPS IP)* | Auto / 300 |
Verify it resolves before you deploy:
```bash
dig +short api.example.com
# should print your VPS IP
```
DNS can take a few minutes (sometimes longer with Cloudflare proxying â turn the orange cloud off initially so Caddy can issue the cert via HTTP-01).
## 1. Install shipnode in your project
```bash
# npm
npm install -D @devalade/shipnode
# pnpm
pnpm add -D @devalade/shipnode
# yarn
yarn add -D @devalade/shipnode
# bun
bun add -d @devalade/shipnode
```
`shipnode` lives in your project as a dev dependency so every collaborator and your CI uses the same version.
## 2. Generate the config
```bash
npx shipnode init
```
This prompts for framework, package manager, app type, SSH target, domain, and port â then writes `shipnode.config.ts`. You can rerun `init` or edit the file by hand at any time. See the [configuration reference](/docs/configuration/) for every option.
A minimal backend config:
```ts
import { shipnode } from '@devalade/shipnode';
export default shipnode
.backend()
.ssh({ host: '203.0.113.10', user: 'root' })
.deployTo('/var/www/api')
.pm2('api', { instances: 2 })
.port(3000)
.domain('api.example.com')
.healthCheck('/health')
.nodeVersion('22')
.pkgManager('pnpm')
.build();
```
## 3. Provision the server
```bash
npx shipnode setup
```
One-time, idempotent. Installs **mise**, **Node.js**, **PM2** (+ `pm2-logrotate`), **Caddy**, and your package manager. Re-running it is safe â it skips anything already present.
Verify with:
```bash
npx shipnode doctor
```
If anything is red, fix it before deploying.
## 4. Upload secrets
Most apps need environment variables in production â database URLs, API keys, signing secrets. Keep them in a local file (do **not** commit it) and push it to the server once:
```bash
npx shipnode env --file .env.production
```
What this does, step by step:
1. **Reads `.env.production`** from your project root locally.
2. **SSHes into the server** using the host + user from `shipnode.config.ts`.
3. **Writes the file to `/shared/.env`** â outside any release directory, owned by the deploy user, `chmod 600`.
4. Every current and future release gets `.env` **symlinked in** from `shared/.env`, so all releases share the same env without you redeploying when a value changes.
5. **PM2 picks up new values** on the next `shipnode restart` or `shipnode deploy` (both pass `--update-env`).
This separation matters: secrets live in `shared/`, code lives in `releases//`. Rolling back a release does **not** roll back your secrets, and rotating a secret does **not** require a redeploy.
:::tip
If you only have one env file called `.env`, you can omit `--file` and shipnode will use whatever the `env` block in `shipnode.config.ts` points at.
:::
## 5. Deploy
```bash
npx shipnode deploy
```
What happens:
```
rsync ./ -> /var/www/api/releases/20260524160000
install pnpm install --frozen-lockfile
build pnpm run build
symlink current -> releases/20260524160000
pm2 reload api --update-env
health GET /health 200 OK 47ms
deployed https://api.example.com
```
If the health check fails, the symlink stays on the previous release and the failed one is discarded. No partial outage.
## 6. Confirm and operate
```bash
npx shipnode status # PM2 state + current release
npx shipnode logs # stream logs
npx shipnode metrics # PM2 CPU/memory dashboard
```
Need to run a one-off command on the server inside the current release?
```bash
npx shipnode run "node scripts/migrate.js"
```
## 7. Roll back
Something off in production?
```bash
npx shipnode rollback --steps 1
```
The `current` symlink moves back one release and PM2 reloads. The default `keepReleases` is 5, so you have headroom.
## 8. Wire CI (optional)
Generate a ready-to-use GitHub Actions workflow:
```bash
npx shipnode ci github
npx shipnode ci env-sync --all
```
This drops `.github/workflows/deploy.yml` and pushes your `.env` keys to GitHub repository secrets. See the [CI/CD guide](/docs/ci-cd/) for the secrets it expects.
## 9. Harden the server (recommended)
```bash
npx shipnode harden
```
Locks down SSH (key-only, no root), enables UFW, installs `fail2ban`, and turns on unattended security upgrades.
Audit it anytime:
```bash
npx shipnode doctor --security
```
## Troubleshooting
### `Permission denied (publickey)` on any command
The SSH connection is failing before shipnode does anything. Confirm you can connect manually with the same host and user from your config:
```bash
ssh [email protected]
```
If that prompts for a password or fails, your public key isn't in the server's `~/.ssh/authorized_keys`. Fix that first.
### `dig` returns no IP / wrong IP
Your A record hasn't propagated yet, or it points somewhere else. Wait a few minutes and retry. With Cloudflare, turn the proxy (orange cloud) off until Caddy has issued the certificate â the HTTP-01 challenge needs a direct connection to your server on port 80.
### Caddy can't issue an HTTPS certificate
Caddy needs ports **80 and 443 reachable from the public internet** to complete the ACME challenge. Common causes:
- Hosting firewall blocks 80/443 (check your provider's security group / network rules).
- UFW is enabled and didn't allow web traffic â `shipnode harden` opens 80 and 443 automatically, but a manual UFW config might not.
- Cloudflare proxy is on and rewriting the challenge. Toggle it off, deploy, then turn it back on.
Inspect Caddy logs on the server:
```bash
sudo journalctl -u caddy -n 100 --no-pager
```
### Health check fails after deploy
The release was discarded and the symlink stayed on the previous one â your app isn't healthy. Run:
```bash
npx shipnode logs
```
Most common reasons:
- The `healthCheck` path in `shipnode.config.ts` doesn't exist in your app (404).
- The app didn't bind to the port from `.port(...)`.
- A required env var isn't set â re-check `npx shipnode env --file .env.production`.
### `Deploy is locked` even though nothing is running
A previous deploy was killed mid-flight and left a stale lock at `/.shipnode/deploy.lock`. After confirming nothing is actually deploying:
```bash
npx shipnode unlock
```
### Node version mismatch / build fails on the server
`shipnode setup` installs Node via mise based on `nodeVersion` in your config. If you bumped that field after running setup, re-run it:
```bash
npx shipnode setup
```
### Pre-flight check before anything else
When in doubt:
```bash
npx shipnode doctor
```
It validates the config, SSH, sudo, Node, PM2, Caddy, disk space, and the live domain. Fix what it flags before running `deploy`.
## What's next
- [shipnode.config.ts reference](/docs/configuration/) â every method, every option
- [Multi-environment](/docs/environments/) â add a staging deploy
- [Workers](/docs/workers/) â long-running PM2 processes alongside the web app
- [Cloudflare Tunnel](/docs/cloudflare/) â close inbound ports entirely
- [Backups](/docs/backups/) â scheduled `pg_dump` + file backups to S3