This site is plain HTML and CSS — no build step, no bundler, no framework. Just files. So the deployment pipeline can be equally simple: push to master, CircleCI picks it up, and the src/ directory gets uploaded straight to the web server over FTP. Done.
Here's how it works end to end.
There is exactly one dependency — ftp-deploy. It handles connecting to the FTP server, diffing the local and remote file trees, and uploading whatever has changed.
npm install ftp-deploy --save-dev
package.json ends up being about as minimal as it gets:
{
"devDependencies": {
"ftp-deploy": "^2.4.7"
}
}
The deploy script lives in deploy.js at the root of the project. It reads FTP credentials from environment variables — never hardcoded — and uploads everything inside src/ to the server root.
const FtpDeploy = require('ftp-deploy');
const ftpDeploy = new FtpDeploy();
const FTP_USER = process.env.FTP_USER;
const FTP_PASS = process.env.FTP_PASS;
const FTP_SERVER = process.env.FTP_SERVER;
const config = {
user: FTP_USER,
password: FTP_PASS,
host: FTP_SERVER,
port: 21,
localRoot: __dirname + "/src",
remoteRoot: "/",
include: ["*"],
exclude: [
"dist/**/*.map",
"node_modules/**",
"node_modules/**/.*",
".git/**",
],
deleteRemote: true,
forcePasv: true,
sftp: false,
};
ftpDeploy
.deploy(config)
.then((res) => console.log('finished', res))
.catch((err) => {
console.log(err);
throw err;
});
A few things worth noting here. deleteRemote: true means anything on the server that no longer exists locally gets cleaned up automatically — the remote always mirrors src/ exactly. forcePasv: true is usually needed when the server is behind a firewall. sftp: false keeps it on plain FTP port 21.
The pipeline config lives at .circleci/config.yml. It uses a Node.js Docker image, runs npm install to pull in ftp-deploy, then runs the deploy script.
version: 2.1
jobs:
deploy:
docker:
- image: cimg/node:23.11.0
steps:
- checkout
- run: npm install
- run: node deploy.js
workflows:
do_it:
jobs:
- deploy:
filters:
branches:
only: master
The filters block is the important part — the pipeline only runs on pushes to the master branch. Feature branches and PRs are ignored entirely, so you can work freely without accidentally triggering a deploy.
The three credentials — FTP_USER, FTP_PASS, and FTP_SERVER — are set in the CircleCI project settings under Project Settings → Environment Variables. CircleCI injects them at runtime so they're never stored in the repository.
End to end, a deploy looks like this:
git add .
git commit -m "update post"
git push origin master
# CircleCI detects the push to master
# → pulls the Node 23 Docker image
# → checks out the repo
# → npm install (pulls ftp-deploy)
# → node deploy.js (uploads src/ to server)
# → done
The whole pipeline typically completes in under a minute. For a static site with no build step this is about as lean as a deployment pipeline can get — one package, one script, one config file.