Deploying beefone.dev with CircleCI

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.

The package

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

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 CircleCI config

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.

Environment variables

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.

The full flow

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.