Local to Server FTP deploys for Static Sites

I've been enjoying building this site - a static site with Umbraco and Astro - and then copying and pasting the files onto a server with Filezilla (as God intended).

But I decided that maybe there was a better way.

For this small personal project I didn't want to use a standard CI/CD pipeline, I wanted to have a specific "publish" command instead of automatically pushing new content. That being said, this approach can very easily be migrated to a GitHub Action, and for larger projects with more contributors I would recommend that approach.

The most blunt consideration I had was to just delete everything on the server every time and replace with the new files. I didn't want to just replace the updated files since that could lead to orphaned pages etc. 

I ended up coming across ftp-deploy which perfectly handles the deploy task, while also creating a state file - .ftp-deploy-sync-state.json - so that future deployments only adjust what is already online.

I ended up putting together a deploy.js module to run this process. You should be able to just run it from the CLI, as part of a package.json defined actino but I ran into problems reading environment variables.

import { deploy } from '@samkirkland/ftp-deploy';
import { config } from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load .env.local
config({ path: resolve(__dirname, '.env.local') });
const isDryRun = process.argv.includes('--dry-run');
try {
await deploy({
server: process.env.FTP_SERVER,
username: process.env.FTP_USER,
password: process.env.FTP_PASS,
"local-dir": './dist/',
"server-dir": './public_html/',
protocol: 'ftps',
logLevel: 'verbose',
dryRun: isDryRun
});
console.log('✅ Deployment completed successfully!');
} catch (error) {
console.error('❌ Deployment failed:', error.message);
process.exit(1);
}

So now I have a script for pushing static files to a server with minimal server usage!

One more set of clicks removed from my workflow :)