Polynomial World

Engineering

Deploy Astro to Shared Hosting with GitHub Actions (FTPS)

Polynomial World 9 min read

You can deploy an Astro site to ordinary shared hosting without logging in to an FTP client. A GitHub Actions workflow builds the site every time you push to main, then uploads the dist/ folder to your host’s web root over FTPS. This site is deployed that way, and this guide walks through the same setup with placeholder names.

Key facts (as of October 2026)

What does the setup look like?

The flow has four parts:

  1. You push a commit to main.
  2. GitHub Actions checks out the repo, installs packages, and runs npm run build.
  3. Astro writes the finished site to dist/.
  4. The FTP deploy action uploads dist/ to your host’s web root, usually public_html/.

Nothing runs on the server. Shared hosting only serves the static files, which is why this works on a basic cPanel plan.

What do you need before you start?

  • An Astro project in a GitHub repository, with main as the default branch.
  • A shared hosting account that supports FTPS. Your host’s docs or control panel will list the FTP server name and port.
  • An FTP account that can write to the folder your domain serves from.
  • The folder path as the FTP account sees it, for example public_html/. Some FTP accounts start inside a subfolder, so the path can differ from what you see in the file manager.

Why use FTPS instead of plain FTP?

Plain FTP sends your username and password unencrypted. The action’s README describes the three options this way: ftp “provides no encryption”, ftps is “full encryption newest standard (aka ‘explicit’ ftps)”, and ftps-legacy is the older “implicit” standard (FTP-Deploy-Action README).

Use ftps unless your host only supports the implicit kind. The default port is 21, and the README tells you to read your web host’s docs if yours is different.

If your host offers SSH, you have another option. The action’s author points SSH users to a separate web-deploy action, but this guide sticks to FTPS because most shared plans include it.

Step 1: Store the FTP credentials as secrets

Secrets are encrypted values that workflows can read but that people browsing the repo can’t see. GitHub also redacts them from logs (GitHub Docs: Using secrets).

  1. Open your repository on GitHub and click Settings.
  2. Go to Secrets and variables > Actions and open the Secrets tab.
  3. Click New repository secret.
  4. Add these four secrets, one at a time:
Secret nameExample valueWhat it is
FTP_SERVERftp.example.comYour host’s FTP server name
FTP_USERNAMEdeploy@example.comThe FTP account’s login
FTP_PASSWORD(your password)The FTP account’s password
FTP_REMOTE_DIRpublic_html/Target folder, with a trailing slash

If you use the GitHub CLI, gh secret set FTP_PASSWORD prompts for the value so it never lands in your shell history (GitHub Docs: Using secrets).

Storing the remote folder as a secret is optional. We do it so the workflow file contains no details about the server at all.

Step 2: Use variables for public build-time values

Astro reads environment variables at build time. Only variables that start with PUBLIC_ are available in client-side code, and the values are “statically replaced at build time” (Astro docs: Environment variables). In other words, the value ends up inside the HTML or JavaScript that visitors download.

That matters for where you store it. An analytics measurement ID or an ad publisher ID is public anyway, because anyone can view your page source. For these, a GitHub Actions variable is a better fit than a secret. Workflows read variables through the vars context, like ${{ vars.PUBLIC_GA4_ID }} (GitHub Docs: Store information in variables).

To add one, go to Settings > Secrets and variables > Actions, open the Variables tab, and click New repository variable.

A simple rule: if the value would be harmful in the wrong hands, it’s a secret. If it ships to every visitor’s browser, it can be a variable. Never put a private API key in a PUBLIC_ variable, because the build will publish it.

Step 3: Add the workflow file

Create .github/workflows/deploy.yml. Here is a sanitized version of the workflow this site uses:

name: Build and deploy

on:
  push:
    branches: [main]
    paths-ignore:
      - 'docs/**'
      - 'README.md'
  workflow_dispatch:

concurrency:
  group: deploy-main
  cancel-in-progress: true

jobs:
  build-deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install
        run: npm ci

      - name: Build
        run: npm run build
        env:
          PUBLIC_GA4_ID: ${{ vars.PUBLIC_GA4_ID }}

      - name: Deploy dist/ over FTPS
        uses: SamKirkland/FTP-Deploy-Action@v4.4.0
        with:
          server: ${{ secrets.FTP_SERVER }}
          username: ${{ secrets.FTP_USERNAME }}
          password: ${{ secrets.FTP_PASSWORD }}
          protocol: ftps
          local-dir: ./dist/
          server-dir: ${{ secrets.FTP_REMOTE_DIR }}

Check the action’s README for its latest release tag before you copy the version number. Here is what each part does.

on.push.branches: [main] runs the workflow on every push to main. Pushes to other branches do nothing.

concurrency stops two deploys from uploading at the same time. With cancel-in-progress: true, GitHub cancels the older run in the same group and lets the newer one take its place (GitHub Docs: Workflow syntax).

timeout-minutes: 15 is the action README’s own suggestion for limiting a stuck upload. GitHub’s default is 360 minutes.

npm ci installs the exact versions in package-lock.json. Commit the lock file, or this step fails.

The build step’s env passes the variable from Step 2 into Astro. If you also run type checks or tests, add them before the build. This site runs npm run check and npm test there, so a broken commit never reaches the server.

local-dir and server-dir both “must end with trailing slash /,” according to the README. A missing slash is one of the easiest mistakes to make.

Step 4: Skip deploys for doc-only commits

If you keep notes, plans, or a README in the repo, you probably don’t want a deploy every time you edit them. The paths-ignore filter handles this.

GitHub’s rule is simple: “When all the path names match patterns in paths-ignore, the workflow will not run.” If a commit touches one ignored file and one source file, the workflow still runs (GitHub Docs: Workflow syntax).

Two things to watch:

  • You can’t use paths and paths-ignore for the same event. Pick one.
  • Don’t ignore folders the build reads from. If your articles live in src/content/, never list it here.

Step 5: Re-run a deploy by hand

Sometimes you need to redeploy without a new commit. Maybe you changed a variable, or the host had an outage. The workflow_dispatch line in the trigger makes that possible.

According to GitHub, the workflow “must be configured to run on the workflow_dispatch event” and must be in the default branch (GitHub Docs: Manually running a workflow).

To run it from the browser:

  1. Open the Actions tab of the repository.
  2. Select the deploy workflow in the left sidebar.
  3. Click Run workflow, choose main, and confirm.

To run it from a terminal with the GitHub CLI:

gh workflow run deploy.yml --ref main

This is also the step to remember after you change a variable. Variables are read at build time, so the live site keeps the old value until the next build.

Step 6: Verify the deploy

A green check in the Actions tab means the upload finished. It doesn’t prove the site looks right. Check three things:

  1. The run log. Open the run and expand the deploy step. With the default log-level, the action lists the files it changed.
  2. The live page. Open a page you just edited in a private window, so your browser cache doesn’t fool you.
  3. Build-time values. If you set a PUBLIC_ variable, confirm it made it into the HTML:
curl -s https://example.com/ | grep -c "G-XXXXXXX"

A result of 0 means the variable was empty at build time. Check its name in the workflow and in Settings.

To list recent runs from the terminal, use gh run list --workflow=deploy.yml --limit 5.

What can go wrong?

Files land in the wrong folder

The site doesn’t change, or files show up at example.com/public_html/. The FTP account’s starting folder is different from what you assumed. Log in once with an FTP client, note where you land, and set FTP_REMOTE_DIR relative to that. Keep the trailing slash.

To test safely, set dry-run: true on the action. The README says it “prints which modifications will be made with current config options, but doesn’t actually make any changes.”

The connection hangs or times out

The action’s default timeout for FTP operations is 30,000 milliseconds. You can raise it with the timeout input. Also confirm the port and protocol match your host’s FTPS settings. A port meant for implicit FTPS won’t work with protocol: ftps, and the reverse is also true.

FTP uses separate data connections, and some host firewalls limit them. The action’s README doesn’t document a passive-mode setting, so if uploads stall after a successful login, ask your host which FTPS mode and ports they support.

Files you need get deleted, or old files stay

The action keeps a state file on the server, .ftp-deploy-sync-state.json by default, which it uses “to track which files have been deployed.” It compares against that record so it only uploads what changed.

Two options affect deletion. Patterns in exclude “will not be included in the publish/delete process.” The dangerous-clean-slate option “deletes ALL contents of server-dir, even items in excluded.” Leave it off unless you really mean to wipe the folder. If other files live in the same folder, such as a file you uploaded by hand, run a dry run first and read what the action plans to do.

If you override exclude, the README warns that you replace the defaults, which skip .git files and node_modules. Add those patterns back.

Certificate errors

There are two different certificates to think about.

The first is the one your FTP server presents. The action’s security input defaults to loose, which allows the connection “even when the domain is not certificate.” Setting it to strict rejects connections that aren’t authorized by a known certificate authority. Strict is safer, but it fails if your host’s FTP certificate doesn’t match the server name you use.

The second is your website’s HTTPS certificate. The workflow doesn’t touch it. If visitors see a certificate warning after a deploy, the problem is on the hosting side. We had a case where the certificate for the main domain was stuck, and the host’s support team had to issue it. A support ticket is often faster than troubleshooting it yourself.

The build fails in Actions but works on your laptop

Check the Node version. If your package.json has an engines field, the node-version in the workflow should satisfy it. This site’s engines field asks for Node 22.12 or later, so the workflow uses Node 22. Also make sure package-lock.json is committed and up to date.

Checklist

  • FTPS server, username, password, and remote folder saved as repository secrets
  • protocol: ftps set explicitly, with the right port if it isn’t 21
  • local-dir: ./dist/ and server-dir both end with /
  • Public build-time values stored as variables and passed in the build step’s env
  • No private keys in any PUBLIC_ variable
  • paths-ignore lists only docs, never source folders
  • workflow_dispatch added so you can redeploy by hand
  • First deploy tested with dry-run: true
  • Live page and build-time values checked after the first real deploy

Once this is in place, publishing is just a commit to main. The workflow file is short, and most problems come from the folder path, the port, or a missing trailing slash.

Related articles

Engineering

What Vibe Coding Is, and Where It Breaks

Vibe coding means prompting an AI and accepting its code unread. See where it works, where it breaks, and what to check before you ship.

8 min read