Skip to main content

Deploy from Git with CI/CD

Turn every push to your repository into a live deployment. A CI/CD pipeline — either GitHub Actions or GitLab CI — picks up your commit, runs your build, and pushes the output to Flexweg via the API. You never open an SSH session, never drag a folder into a UI; you just git push.

This page is the reference setup. The Docusaurus and Decap CMS blog tutorials use the same pattern — this guide is what you read when you want to wire it up for your own stack (React, Vue, Hugo, Astro, handwritten HTML…).

How it works

git push → CI runner → build step → sync script → Flexweg API → live site

Every push triggers a small Node.js script (sync-flexweg.js) that walks your build output, uploads every file to Flexweg, and removes any files or folders you deleted since the previous commit. No server to maintain, no FTP, no manual deploy button.

Prerequisites

  • You have a Git repository on GitHub or GitLab. If not, see the prerequisites page.
  • You have a Flexweg account and a permanent API key from Account → API.
  • Your site produces a folder of static files at build time (public/, dist/, build/, _site/, out/ — any name works).

Step 1 — Add your API key as a CI secret

Never commit the API key. Add it as a secret variable in your CI provider — it's injected as an environment variable when the workflow runs.

  1. Open your repository on GitHub.
  2. Go to Settings → Secrets and variables → Actions.
  3. Click New repository secret.
  4. Fill in:
    • Name: FLEXWEG_API_KEY
    • Secret: paste your API key
  5. Click Add secret.
Multiple sites, multiple keys

Each Flexweg site has its own API key. If your Git repo deploys to several sites, create one secret per site (e.g. FLEXWEG_API_KEY_BLOG, FLEXWEG_API_KEY_LANDING) and reference the right one in each workflow.

my-site/
├── .github/
│ └── workflows/
│ └── deploy.yml ← GitHub Actions workflow
├── .gitlab-ci.yml ← (or GitLab CI workflow)
├── public/ ← built site (what gets deployed)
│ ├── index.html
│ ├── assets/
│ │ ├── css/
│ │ └── js/
│ └── images/
└── sync-flexweg.js ← deploy script (next step)
  • public/ is what Flexweg serves — the "build output" of your site. If your stack outputs to a different folder (dist/, build/, _site/…), either rename it or point the script at it via the BUILD_DIR environment variable.
  • sync-flexweg.js lives at the repo root, is checked into git, and is invoked by the workflow.

Step 3 — The sync script

Drop this file at the root of your repo. It's zero-dependency (uses Node 20's native fetch), handles binary files via base64, respects Flexweg's rate limit, and removes files you deleted in the last commit.

sync-flexweg.js
// Deploy the BUILD_DIR folder to Flexweg, mirroring file deletions.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const API_KEY = process.env.FLEXWEG_API_KEY;
const BASE_URL = process.env.FLEXWEG_BASE_URL || 'https://www.flexweg.com';
const BUILD_DIR = process.env.BUILD_DIR || 'public';

const BINARY = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'ico', 'pdf', 'woff', 'woff2', 'ttf', 'otf']);
const TEXT = new Set(['html', 'css', 'js', 'json', 'xml', 'txt', 'svg']);

const RATE_PER_MIN = 50;
const GAP_MS = Math.ceil(60000 / RATE_PER_MIN);
let lastAt = 0;
let uploadErrors = 0;
let deleteErrors = 0;

async function rateLimit() {
const wait = lastAt + GAP_MS - Date.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
lastAt = Date.now();
}

async function apiCall(method, url, body) {
await rateLimit();
const res = await fetch(url, {
method,
headers: {
'X-API-Key': API_KEY,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res;
}

// ---------- Upload ----------

async function uploadFile(full, rel) {
const ext = path.extname(full).slice(1).toLowerCase();
if (!BINARY.has(ext) && !TEXT.has(ext)) return;

const isBinary = BINARY.has(ext);
const content = isBinary
? fs.readFileSync(full).toString('base64')
: fs.readFileSync(full, 'utf8');

const body = { path: rel, content };
if (isBinary) body.encoding = 'base64';

try {
await apiCall('POST', `${BASE_URL}/api/v1/files/upload`, body);
console.log(`${rel}`);
} catch (err) {
console.error(`${rel}: ${err.message}`);
uploadErrors++;
}
}

async function walk(dir, base = dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
const rel = path.relative(base, full).replace(/\\/g, '/');
if (entry.isDirectory()) await walk(full, base);
else await uploadFile(full, rel);
}
}

// ---------- Delete ----------

function getDeletedItems() {
let output;
try {
output = execSync('git diff --name-status --diff-filter=D HEAD~1 HEAD', {
encoding: 'utf8',
cwd: process.cwd(),
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
// First commit or shallow clone without HEAD~1 — nothing to delete
return { files: [], folders: [] };
}

const deleted = output
.split('\n')
.filter((line) => line.startsWith('D\t') || line.startsWith('D '))
.map((line) => {
const p = line.substring(2).trim();
return p.startsWith(BUILD_DIR + '/') ? p.substring(BUILD_DIR.length + 1) : null;
})
.filter(Boolean);

const folders = new Set();
const files = [];

for (const rel of deleted) {
const ext = path.extname(rel).slice(1).toLowerCase();
if (BINARY.has(ext) || TEXT.has(ext)) files.push(rel);

const parts = rel.split('/');
for (let i = 1; i < parts.length; i++) {
const folderRel = parts.slice(0, i).join('/');
const folderAbs = path.join(BUILD_DIR, folderRel);
// Only delete the remote folder if it no longer exists locally.
// Otherwise some files still live there (e.g. after a hash-based rename).
if (!fs.existsSync(folderAbs)) folders.add(folderRel);
}
}

// Deepest first so children are removed before their parents
return {
files,
folders: [...folders].sort((a, b) => b.split('/').length - a.split('/').length),
};
}

async function deleteFile(rel) {
try {
await apiCall('DELETE', `${BASE_URL}/api/v1/files/delete?path=${encodeURIComponent(rel)}`);
console.log(`🗑 ${rel}`);
} catch (err) {
console.error(`✗ delete ${rel}: ${err.message}`);
deleteErrors++;
}
}

async function deleteFolder(rel) {
try {
await apiCall('DELETE', `${BASE_URL}/api/v1/files/delete-folder?path=${encodeURIComponent(rel)}`);
console.log(`🗑 ${rel}/ (folder)`);
} catch (err) {
if (err.message.includes('No files found')) return; // already gone
console.error(`✗ delete folder ${rel}: ${err.message}`);
deleteErrors++;
}
}

// ---------- Main ----------

async function main() {
if (!API_KEY) {
console.error('FLEXWEG_API_KEY is not set');
process.exit(1);
}
if (!fs.existsSync(BUILD_DIR)) {
console.error(`Build directory '${BUILD_DIR}' not found. Run your build step first.`);
process.exit(1);
}

console.log(`Syncing ${BUILD_DIR}/ → ${BASE_URL}\n`);

// 1. Remove files + folders deleted in the last commit
const { files: deletedFiles, folders: deletedFolders } = getDeletedItems();
if (deletedFiles.length || deletedFolders.length) {
console.log(`Removing ${deletedFiles.length + deletedFolders.length} item(s) deleted since HEAD~1…`);
for (const f of deletedFiles) await deleteFile(f);
for (const d of deletedFolders) await deleteFolder(d);
console.log('');
}

// 2. Upload everything in BUILD_DIR
await walk(BUILD_DIR);

const total = uploadErrors + deleteErrors;
if (total > 0) {
console.error(`\n${total} operation(s) failed.`);
process.exit(1);
}
console.log('\n✅ Deploy complete.');
}

main();

Key behaviours:

  • Zero dependencies — runs on Node 20+ directly, no npm install required for this script.
  • Environment-configurable — override BUILD_DIR to point at dist/, build/, _site/, out/, etc.
  • Rate-limited — caps at 50 requests/minute to stay inside Flexweg's per-IP limit.
  • Binary-aware — images, PDFs and fonts are auto-base64-encoded before upload.
  • Deletion-awaregit diff HEAD~1 HEAD detects files removed in the last commit and mirrors the deletion remotely. Folders are removed only when they're completely gone on your side, so a hash-rename (e.g. main.abc123.jsmain.xyz789.js) doesn't accidentally wipe the folder.
  • CI-friendly — exits with status 1 if any upload or delete fails, making the workflow red.

Step 4 — The GitHub Actions workflow

Create .github/workflows/deploy.yml:

.github/workflows/deploy.yml
name: Deploy to Flexweg

on:
push:
branches: [main]
workflow_dispatch: {} # allow manual re-runs from the Actions tab

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # required for deletion detection

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci # remove if you have no package.json

- name: Build site
run: npm run build # adjust to your stack; omit for plain HTML

- name: Deploy to Flexweg
env:
FLEXWEG_API_KEY: ${{ secrets.FLEXWEG_API_KEY }}
# Uncomment if your build output isn't in ./public
# BUILD_DIR: dist
run: node sync-flexweg.js

Push to main and watch the run in the Actions tab. A typical run takes 1–3 minutes depending on file count.

fetch-depth: 2 / GIT_DEPTH: 2 is required

By default, GitHub Actions and GitLab CI do a shallow clone that fetches only the latest commit. Without fetch-depth: 2 (or GIT_DEPTH: 2), git diff HEAD~1 HEAD fails silently and files you deleted won't be removed from Flexweg. Always set these.

Step 5 — Trigger your first deploy

Commit everything and push to main:

git add -A
git commit -m "Set up Flexweg auto-deploy"
git push

Open the Actions / Pipelines tab in GitHub or GitLab. You should see your job running, with a log that looks like:

Syncing public/ → https://www.flexweg.com
Removing 0 item(s) deleted since HEAD~1…
✓ index.html
✓ about.html
✓ assets/css/style.css
✓ assets/js/script.js

✅ Deploy complete.

Visit your Flexweg URL — your site is live.

Common adjustments

My build folder isn't public/

Set BUILD_DIR in the workflow:

- name: Deploy to Flexweg
env:
FLEXWEG_API_KEY: ${{ secrets.FLEXWEG_API_KEY }}
BUILD_DIR: dist # ← or: build, _site, out, ...
run: node sync-flexweg.js

I deploy a React / Vue / Astro / Next.js export

Add a build step before the deploy:

- name: Build
run: npm run build # produces dist/ or build/

- name: Deploy to Flexweg
env:
FLEXWEG_API_KEY: ${{ secrets.FLEXWEG_API_KEY }}
BUILD_DIR: dist # or whatever your tool produces
run: node sync-flexweg.js

I want to fetch external data at build time

Run the data-fetching step before npm run build. Example for a product catalog pulled from an API — see Connect to external databases for the full pattern.

I want to force a full re-upload

If a previous failed deploy left files missing on Flexweg, re-run the workflow with a FULL_SYNC=1 environment variable. The script does this implicitly by uploading everything, but you can also add a workflow-dispatch input to your YAML to let you trigger a manual "re-sync everything" run from the Actions UI.

I manage multiple Flexweg sites from one repo

Create one secret per site (FLEXWEG_API_KEY_SITE1, FLEXWEG_API_KEY_SITE2), then one job per site in the workflow, each referencing the right secret.

Troubleshooting

401 Unauthorized

Your FLEXWEG_API_KEY secret is missing, wrong, or has been revoked.

  • Check the secret name matches the one in the workflow (FLEXWEG_API_KEY by default).
  • Re-copy the key from Account → API and update the secret.
  • If you used a temporary key, it has expired — generate a new one (permanent keys don't expire).
Build directory 'public' not found

Either your build didn't run, or it outputs to a different folder.

  • Add a npm run build step before the deploy step.
  • Or set BUILD_DIR to match what your tool produces.
403 — Storage or file limit exceeded

Your plan's quota is full.

Files I deleted are still on Flexweg

The runner did a shallow clone and can't see the previous commit.

  • GitHub: add fetch-depth: 2 to the actions/checkout@v4 step.
  • GitLab: add GIT_DEPTH: 2 to your top-level variables: block.

After fixing, delete a test file, commit, push, and check the logs — you should see 🗑 file.html in the output.

Images or fonts are corrupted on the live site

Binary files need base64 encoding. The script handles this for these extensions: jpg jpeg png gif webp ico pdf woff woff2 ttf otf. If you have a binary file with a different extension (e.g. .avif, .mp4), add it to the BINARY set at the top of sync-flexweg.js. For videos, use the dedicated video endpoint instead — it's optimized for large files.

Hitting rate limits mid-deploy

The script throttles itself to 50 req/min by default. If you still see 429 errors, lower RATE_PER_MIN in the script to 40 or 30.

Where to next