portway-cpanel
v1.1.0
Published
Deploy local or GitHub projects to your own cPanel/SSH host — declarative config, incremental uploads, safe dry-runs.
Downloads
1
Maintainers
Readme
Portway
Deploy a local or GitHub-hosted project to your own cPanel / SSH host — as simply as:
portway deployPortway is not a hosting platform and not a Vercel-style PaaS. You bring your own cPanel account, server, domain, and credentials. Portway is only the deployment bridge between your code and your host:
Local Project ──────┐
├────> portway CLI ────> Your cPanel host
GitHub Repository ───┘ (SFTP, FTP, or GitHub — server clones/builds itself)Installation
npm install -g portwayThe CLI is installed under three interchangeable command names — use whichever you like, they all run the exact same binary:
portway deploy
pway deploy # short alias
portway-cpanel deploy # explicit alias, if "portway" collides with
# something else already on your PATHQuick start
cd your-project
portway init # answer a few questions, creates Portwayfile + .portwayignore
portway dry-run # see what WOULD be uploaded, with zero server writes
portway deploy # actually deployportway init walks you through an interactive setup and writes a
fully-commented Portwayfile explaining what each directive does, based
on your answers (local vs. GitHub source, SFTP vs. FTP, SSH key vs.
password, etc.).
Credentials: .env interpolation
Any value in the Portwayfile can reference an environment variable with
${VAR_NAME} — most commonly for PASSWORD, but it works for any field:
HOST example.com
USER my-ftp-user
PASSWORD ${CPANEL_FTP_PASSWORD}Portway resolves ${CPANEL_FTP_PASSWORD} from, in order of precedence:
- A real environment variable (e.g. a CI secret) — wins if set.
- A
.envfile in the project root.
.env is never uploaded and should stay out of git via .gitignore.
Inline secrets in the Portwayfile itself (a literal password or
passphrase instead of ${VAR}) are rejected — this is enforced, not just
a convention. If a referenced variable isn't defined anywhere, portway
deploy/dry-run fails immediately with a clear error naming the missing
variable, rather than silently sending a broken value.
portway init sets all of this up for you automatically if you choose
password auth over an SSH key, or if your SSH key has a passphrase
(PASSPHRASE) — it writes the placeholder to the Portwayfile and the
real value to .env for you.
Example Portwayfile (SFTP)
NAME my-vite-app
SOURCE local
RUN npm run build
OUTPUT dist
HOST example.com
PORT 22
USER my-cpanel-user
KEY ~/.ssh/id_ed25519
TARGET public_html
CLEAN false
IGNORE *.map
IGNORE *.logExample Portwayfile (FTP)
NAME my-vite-app
SOURCE local
RUN npm run build
OUTPUT dist
HOST ftp.example.com
PORT 21
USER my-ftp-user
PASSWORD ${CPANEL_FTP_PASSWORD}
TRANSPORT ftp
SECURE true
TARGET public_htmlGitHub source mode
SOURCE github is fundamentally different from SOURCE local: your
laptop never uploads the project. Instead, Portway SSHes into your
server and tells it to clone the repo and build itself there:
GitHub ──clone/fetch──> Your cPanel server ──build──> TARGET
▲
portway CLI only triggers this,
over SSH, from your machineThis is the recommended way to handle large repositories — nothing is ever zipped up and pushed from your laptop.
NAME my-app
SOURCE github
REPO your-username/your-repo
BRANCH main
RUN npm ci && npm run build
OUTPUT dist
HOST example.com
USER my-cpanel-user
KEY ~/.ssh/id_ed25519
TARGET public_htmlAuthentication is two separate things, and Portway only handles one of them:
- Portway → your server: the usual
KEY/PASSWORDin thePortwayfile, same as local/FTP deployments. - Your server → GitHub: an SSH deploy key registered directly on
GitHub (repo Settings → Deploy keys) and present in the server's
~/.ssh, not your laptop's. This is entirely outside Portway's knowledge, so a private repo works exactly like a public one from Portway's point of view. IfREPOisowner/repo, Portway builds the SSH clone URL ([email protected]:owner/repo.git) automatically; a full URL (https://...orgit@...) is also accepted as-is.
How a deploy actually works, step by step:
- SSH into
HOSTasUSER. - If a previous clone exists on the server:
git fetch+ hard reset toorigin/BRANCH(fast, incremental). Otherwise: freshgit clone --branch BRANCH. - If
RUN/BUILDsteps are set, they run inside the clone, on the server. - The deployable output (
OUTPUT, or the repo root if no build step) is copied into a staging directory — excluding.git,node_modules,.env, and yourIGNOREpatterns, same as local mode. dry-runstops here and diffs the staged build againstTARGET.deployadditionally does an atomic swap: moves the currentTARGETaside, moves the new staged build into place, then removes the old one.
Because the whole thing is an atomic swap, TARGET always ends up an
exact mirror of the latest build (except for anything matched by
PRESERVE — see below).
RUN and COPY
Dockerfile-style build steps. RUN <command> is repeatable and runs in
order — locally for SOURCE local, on the server (inside the freshly
cloned repo) for SOURCE github:
RUN npm install
RUN npm run build
OUTPUT distThe first command that fails stops the rest — a partially-succeeded build is never scanned or deployed.
COPY <from> <to> copies a file or directory from the project/repo root
into the deploy output, after RUN steps finish and before
anything gets scanned for deployment:
RUN npm run build
OUTPUT dist
COPY package.json package.json
COPY prisma prismaThe main reason you'd need this: a compiled dist/ typically doesn't
include package.json (or a prisma/ schema directory, etc.) — so if
you're running npm install or npx prisma migrate deploy on the server
afterward via CMD (below), there's nothing there for it to work with
unless you COPY it in explicitly. from is relative to the project/repo
root; to is relative to the deploy output — both directories and single
files work, and missing destination folders are created automatically.
Minification and concurrency
For local deployments, you can optionally minify every JavaScript file in
OUTPUT after the build finishes and before Portway compares or uploads the
files:
RUN npm run build
OUTPUT dist
MINIFY trueMinification reduces JavaScript size, which can make pages and applications
load faster and reduces the amount of data sent to your server. It uses
Terser to compress and mangle JavaScript. Only files inside OUTPUT are
changed; your source files are left untouched.
Portway processes files concurrently to make deployments faster. By default, it uses up to 6 concurrent files, adjusted down to the number of CPU cores available on the computer running Portway. This setting applies to both optional minification and parallel uploads:
CONCURRENCY 4CONCURRENCY is optional. Use a smaller value, such as 1, on a computer or
server with limited resources or strict connection limits. Use a larger value
when your host and computer can handle more parallel work; Portway accepts
values up to 16. BUNDLE true remains a single archive upload and does not
use per-file concurrency.
CMD
Run one or more commands on the server, right after a deploy that
actually changed something — the classic case is running database
migrations or npm install immediately after pushing new code, without a
separate manual SSH step:
CMD npx prisma migrate deploy
CMD pm2 restart apiMultiple CMD lines run in order; the first one that fails stops the
rest and marks the whole deploy as failed — but the files themselves are
left deployed as-is. Commands run with the server's working directory set
to TARGET, so a command like npm install acts on the app you just
deployed.
Requires SSH exec, so it only works with TRANSPORT sftp or SOURCE
github — plain FTP has no way to run a remote command, and CMD with
TRANSPORT ftp is rejected at config-load time with a clear error.
dry-runlists what would run, without executing anything.CMDonly runs when the deploy actually changed something — a genuine no-op redeploy skips it entirely, same as it skips the file transfer.
Important gotcha for SOURCE github: anything a CMD command writes
into TARGET — most commonly npm install's node_modules/ — isn't part
of your git repo. Without PRESERVE, the next deploy's atomic swap will
wipe it out again, forcing a full reinstall on every single deploy. Add it
to PRESERVE:
CMD npm install --omit=dev
PRESERVE node_modulesPutting it all together — a compiled backend that needs its dependencies installed and migrations run on the server:
RUN npm install
RUN npm run build
OUTPUT dist
COPY package.json package.json
COPY prisma prisma
TARGET api
CMD npm install --omit=dev
CMD npx prisma migrate deploy
CMD pm2 restart apiINIT
If CMD npm install fails with something like:
✗ CMD command failed (exit code 127): npm install
bash: npm: command not found...that's a very common shared-hosting quirk, not a Portway bug. SSH
command execution is non-interactive, and non-interactive shells often
don't source the same profile scripts an interactive login shell does.
Node/npm installed via cPanel's Node.js Selector typically needs its
environment "activated" before node/npm land on PATH at all.
INIT fixes this by running setup commands before every CMD
command and every RUN step (for SOURCE github):
INIT source /home/user/nodevenv/appname/18/bin/activateCMD npm install
CMD npx prisma migrate deployMultiple INIT lines are joined with && and prepended fresh to each
command — you never need to repeat the setup line yourself on every
CMD/RUN line; Portway does that for you automatically.
Finding the right INIT command for your host: SSH into the server
manually, do whatever you'd normally do to get node/npm working
interactively, then run which npm to see the real path. If your host
uses cPanel's Node.js Selector, the "Setup Node.js App" page shows an
exact "Enter to the virtual environment" command you can use directly as
your INIT line. If npm is just installed somewhere directly on disk
without any activation script, a plain PATH export works too:
INIT export PATH="$PATH:/opt/cpanel/ea-nodejs18/bin"BUNDLE mode
For SFTP deploys with many small files, uploading them one at a time means
one round trip per file — slow, especially over a high-latency connection.
BUNDLE true instead zips every new/changed file locally into a single
archive, uploads just that one file, then extracts it on the server:
TRANSPORT sftp
BUNDLE trueA few things worth knowing:
- SFTP only — extracting the zip requires running a command on the
server, which plain FTP can't do.
BUNDLE truewithTRANSPORT ftpis rejected at config-load time. - Requires
unzipon the server — virtually universal on cPanel/Linux hosting. - All-or-nothing — a bundle either extracts completely or the whole batch is treated as failed, and the next deploy retries everything cleanly.
- Deletions (files removed locally) still happen individually.
- Doesn't apply to
SOURCE github, which already builds and deploys entirely on the server.
PRESERVE
Some files only make sense living on the server — a cPanel-managed
.htaccess, an SSL certificate validation path under .well-known/, an
uploads/ directory your app writes to at runtime. PRESERVE tells
Portway to leave matching paths alone entirely:
PRESERVE .htaccess
PRESERVE .well-knownPatterns support glob wildcards (*.log, uploads/*), same as IGNORE.
What this does depends on the deployment mode:
- Local/SFTP/FTP: matching paths are excluded from deletion. Portway
only ever deletes files it previously uploaded itself, but
PRESERVEmakes the guarantee explicit. SOURCE github: this is wherePRESERVEmatters most. Since a GitHub-source deploy atomically replaces the entireTARGETdirectory, anything server-only sitting inTARGETwould otherwise be permanently lost the moment the swap happens. WithPRESERVEset, Portway copies matching files out of the currentTARGETinto the new build before swapping, so they carry over intact.
Progress and concurrent uploads
By default, Portway uploads up to 4 files at once over separate
connections instead of strictly one after another — a real speedup for
projects with many small files. Works for both TRANSPORT sftp and
TRANSPORT ftp. Tune it with:
CONCURRENCY 6Set CONCURRENCY 1 to go back to fully sequential uploads — useful if
your host caps how many simultaneous connections one account can open.
This comes up more often with TRANSPORT ftp than sftp in practice:
some FTP daemons on shared hosting cap concurrent connections per account
quite low (2-4 isn't unusual), and you'll see connection-refused errors if
you push past that. If you're not sure, start at the default and lower it
only if you hit errors.
Progress display adapts to your terminal and the size of the deploy:
- Interactive terminal, more than ~15 files: a single live-updating progress bar instead of one line per file.
- Interactive terminal, fewer files: the classic per-file
+/↑lines. - Piped output / CI logs: always per-file lines.
dry-run similarly caps each category (added/modified/deleted/unchanged)
at 25 listed files, summarizing the rest as ... and N more. This doesn't
apply to BUNDLE mode (already a single transfer) or SOURCE github (no
per-file upload step to begin with).
Ignoring files
.portwayignore (checked into your project) plus inline IGNORE rules in
the Portwayfile control what never gets deployed:
IGNORE *.map
IGNORE *.lognode_modules, .git, and .env are excluded by default even without
any rules of your own.
Incremental deployment
For SOURCE local deploys, Portway records a SHA-256 hash of every
uploaded file. The next deploy only uploads new or changed files, and
safely deletes files that were removed locally (as long as they were
previously managed by Portway) — set CLEAN false if you'd rather it
never delete anything on the server automatically.
Testing before you deploy for real
Always run portway dry-run before your first portway deploy against a
new host — it shows exactly what would be uploaded, changed, and deleted
with zero writes to your server, so you can catch a misconfigured TARGET
or an overly broad IGNORE/PRESERVE pattern before it touches
production.
