The personal website of Scott W Harden

FTP Deploy with GitHub Actions

Deploy content over FTP using GitHub Actions and no dependencies

This article describes how I use GitHub Actions to deploy content using FTP without any third-party dependencies. Code executed in continuous deployment pipelines may have access to secrets (like FTP credentials and SSH keys). Supply-chain attacks are becoming more frequent, including self-sabotage by open-source authors. Without 2FA, the code of well-intentioned maintainers is one stolen password away from becoming malicious. For these reasons I find it imperative to eliminate third-party Actions from my CI/CD pipelines wherever possible.

⚠️ WARNING: Third-party Actions in the GitHub Actions Marketplace may be compromised to run malicious code and leak secrets. There are dozens of public actions claiming to facilitate FTP deployment. I advise avoiding third-party actions in your CI/CD pipeline whenever possible.

This article assumes you have at least some familiarity with GitHub Actions, but if you’re never used them before I recommend taking 5 minutes to work through the Quickstart for GitHub Actions.

FTP Deployment Workflow

This workflow demonstrates how to use LFTP inside a GitHub Action to transfer files/folders with FTP without requiring a third-party dependency. Users can copy/paste this workflow and edit it as needed according to the LFTP manual.

name: 🚀 FTP Deploy
on: [push, workflow_dispatch]
    runs-on: ubuntu-latest
      - name: 🛒 Checkout
        uses: actions/checkout@v2
      - name: 📦 Get LFTP
        run: sudo apt install lftp
      - name: 🛠️ Configure LFTP
        run: mkdir ~/.lftp && echo "set ssl:verify-certificate false;" >> ~/.lftp/rc
      - name: 🔑 Load Secrets
        run: echo "machine ${{ secrets.FTP_HOSTNAME }} login ${{ secrets.FTP_USERNAME }} password ${{ secrets.FTP_PASSWORD }}" > ~/.netrc
      - name: 📄 Upload File
        run: lftp -e "put -O /destination/ ./README.md" ${{ secrets.FTP_HOSTNAME }}
      - name: 📁 Upload Folder
        run: lftp -e "mirror --parallel=100 -R ./ffmpeg/ /ffmpeg/" ${{ secrets.FTP_HOSTNAME }}

This workflow uses GitHub Encrypted Secrets to store secret values:

How to Verify the Host Certificate

Extra steps can be taken to record the host’s public certificate, store it as a GitHub Encrypted Secret, load it into the GitHub Action runner, and configure LFTP to compare against at run time.

openssl s_client -connect example.com:21 -starttls ftp -showcerts
      - name: 🛠️ Configure LFTP
        run: |
          mkdir ~/.lftp
          echo "set ssl:ca-file ~/.lftp/certs.crt;set ssl:check-hostname no;" >> ~/.lftp/rc
          echo "${{ secrets.FTP_CERTS_BASE64 }}" | base64 --decode > ~/.lftp/certs.crt          


To avoid storing passwords to disk you can pass them in with each lftp command using the -u argument. See the LFTP Documentation for details.

Although potentially insecure, some GitHub Marketplace Actions offer compelling features: One of the most popular is SamKirkland’s FTP Deploy Action which has advanced features like the use of server-stored JSON files to store file hashes to detect and selectively re-upload changed files. I encourage you to check them out, even though I try to avoid passing my secrets through third-party actions wherever possible.

Favor SSH and rsync over FTP and lftp where possible because rsync is faster, more secure, and designed to prevent needless transfer of unchanged files. I recently wrote about how to safely deploy over SSH using rsync with GitHub Actions.