Deploy a static site to cPanel via curl FTPS in one Makefile target
A repeatable, scriptable, no-UI-clicks deploy from your laptop to a Namecheap Stellar shared host.
This is the deploy pipeline I run for secnull's static export to a Namecheap Stellar Plus account. Same pattern works on any cPanel host that exposes FTPS.
What success looks like
After make publish, a freshly-rendered dist/ tree is at the live web root, the URL responds with the new content, and zero credentials touched the command line or shell history.
Prerequisites
- A cPanel hosting account with at least one FTP user. (Account-main or a domain-scoped sub-user; I use a sub-user scoped to the docroot.)
curl7.64+ on the deploy machine. Windows 10+ ships it; Linux/macOS have it.curl --versionshould listftpsin Protocols.- Python 3.8+ with
pip install keyring. The keyring package uses Windows Credential Manager / macOS Keychain / Secret Service on Linux as the backend automatically. - The Ed25519 public hostname of the cPanel server. Find it once with the snippet in my earlier tip on FTPS cert hostnames.
Steps
1. Store the FTP password in your OS keychain.
# Windows
cmdkey /add:ftp.example-host.com /user:deploy@yourdomain.com /pass:<password>
# macOS
security add-internet-password \
-a "deploy@yourdomain.com" -s "ftp.example-host.com" -w
# (you'll be prompted for the password without echo)
# Linux (gnome-keyring or kwallet via secret-service)
secret-tool store --label="cpanel ftps" \
service ftp.example-host.com \
account deploy@yourdomain.com
The point of this step: the password is in OS-managed encrypted storage, not in .bashrc, not in your repo, not in your shell history.
2. Write a small Python helper that reads the password and runs the upload loop.
Save this as scripts/publish.py:
#!/usr/bin/env python3
"""publish.py — push ./dist to the live host via FTPS."""
import os
import pathlib
import subprocess
import sys
HOST = "premium-N.web-hosting.com" # the TLS-verified hostname
USER = "deploy@yourdomain.com"
WCM_TARGET = "ftp.example-host.com" # whatever you stored under
DIST = pathlib.Path(__file__).resolve().parent.parent / "dist"
def get_password() -> str:
if env := os.environ.get("DEPLOY_FTP_PASS"):
return env
import keyring
cred = keyring.get_credential(WCM_TARGET, USER)
if cred is None or not cred.password:
sys.exit(f"no credential in keyring for {WCM_TARGET}/{USER}")
return cred.password
def main():
if not DIST.is_dir():
sys.exit(f"{DIST} not found — run `make export` first")
pw = get_password()
files = []
for root, _, names in os.walk(DIST):
for n in names:
files.append(pathlib.Path(root) / n)
files.sort()
fail = 0
for f in files:
rel = f.relative_to(DIST).as_posix()
rc = subprocess.run([
"curl", "--silent", "--show-error", "--ssl-reqd",
"--ftp-create-dirs",
"--user", f"{USER}:{pw}",
"-T", str(f),
f"ftp://{HOST}/{rel}",
]).returncode
if rc != 0:
print(f"FAIL: {rel}", file=sys.stderr)
fail += 1
print(f"uploaded {len(files) - fail}; failed {fail}")
sys.exit(0 if fail == 0 else 1)
if __name__ == "__main__":
main()
The two key choices:
- Password reads from keyring or
DEPLOY_FTP_PASSenv var, never argv. CI sets the env var from a repo secret; local runs use keyring. Same script works in both. --ftp-create-dirslets curl mkdir missing remote subdirs as it goes. Without it, the first time you uploadaudits/some-slug/index.html, curl fails becauseaudits/some-slug/doesn't exist remotely.
3. Wire the Makefile target.
.PHONY: publish
publish: export
python scripts/publish.py
Now make publish is one command: re-render dist/, then upload every file.
4. Test it.
make publish
# uploaded 39; failed 0
curl -s https://yourdomain.com/some-page-you-just-changed | grep <expected-string>
If the response shows the new content, you're done. If it shows old content, see the cache gotcha below.
Common gotchas
Cert hostname mismatch. curl: (60) SEC_E_WRONG_PRINCIPAL means the host you connected to doesn't match the cert. Fix: connect to the cert's actual CN, not your DNS alias. See the tip on FTPS cert hostnames.
Don't delete cPanel-managed files. When you upload, curl overwrites existing files with the same path but doesn't touch others. Files like .ftpquota, .hcflag, cgi-bin/, and anything under .well-known/ (AutoSSL writes here) are managed by cPanel — leave them alone. If you ever want a "clean deploy" that removes stale files, list the remote dir, diff against dist/, and delete intentionally.
Cache lifetimes. Apache's mod_expires defaults can hold HTML for 5 minutes. If you fix a typo and reload, you may see the old version. Force-refresh in the browser, or shorten ExpiresByType text/html in your .htaccess.
FTP user chroot. If your FTP user lands in an empty directory containing only .ftpquota, the cPanel sub-user wasn't pointed at the docroot. Fix in cPanel → FTP Accounts → Change Directory → set to public_html (primary domain) or public_html/yoursite.com (addon domain). Verify by listing — you should see index.html, .htaccess, etc.
Special characters in passwords. --user "user:pass" with curl splits on the first :, so : in the username breaks parsing but : in the password works. Other special chars (@, {, )) are fine because the password reaches curl through Python's subprocess — no shell interpretation. If you're constructing the URL form (ftp://user:pass@host/path), URL-encode the password instead.
Why not rsync over SSH?
If your shared host has SSH enabled, rsync -av --delete dist/ user@host:public_html/ is shorter and gives you a real diff. Namecheap Stellar Plus turns SSH off by default; turning it on for the deploy account expands your attack surface from "FTPS sub-user scoped to one directory" to "shell user with home access." For a one-way static deploy, FTPS is the smaller blast radius.
What this doesn't do
- No CDN cache purge. If you front this with Cloudflare, add an API call after the upload loop.
- No deploy notification. Add a Slack/Discord webhook in the script if you want one.
- No rollback. The previous version's files are overwritten in place. If that worries you, snapshot
dist/(or the remote tree) before each deploy.
The reason I'd reach for this over a fancier deploy tool: make publish is one command, it works the same on any laptop with curl + Python, the only secret lives in the OS keychain, and there's nothing to update when the docs for the deploy tool's next major version drop. That's it.