Official crservers.com reusable workflow for sites we host: build a static export (for example Next.js output: "export") and publish over FTP/FTPS using SamKirkland/FTP-Deploy-Action.
This repository is maintained by Edenia for the crservers.com hosting product. Customer application repos stay thin: they call this workflow and supply FTP secrets.
Customer repos should set packageManager in package.json (for example "packageManager": "pnpm@9.15.9"). pnpm/action-setup reads that field and no separate pnpm version is passed from the workflow (avoids mismatch with a pinned major in CI).
Add a workflow that references this repo (pin a tag such as @v1 in production instead of @main):
name: Deploy static site
on:
push:
branches: [main]
permissions:
contents: read
actions: write
jobs:
deploy-site:
uses: edenia/crservers-static-deploy/.github/workflows/deploy-static-site.yml@v1
secrets:
FTP_HOST: ${{ secrets.FTP_HOST }}
FTP_USER: ${{ secrets.FTP_USER }}
FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }}
FTP_REMOTE_PATH: ${{ secrets.FTP_REMOTE_PATH }}
with:
site_url: ${{ vars.SITE_URL }}Configure Settings → Secrets and variables → Actions:
- Secrets (required):
FTP_HOST,FTP_USER,FTP_PASSWORD,FTP_REMOTE_PATH - Variables (optional):
SITE_URL— public URL such ashttps://dev.example.com/(not sensitive; shown in the deploy job summary)
Because this reusable workflow lives in another repository, the caller cannot use secrets: inherit. Map each secret under secrets: as shown above (same names on both sides).
| Secret | Description |
|---|---|
FTP_HOST |
crservers FTP/FTPS hostname |
FTP_USER |
FTP username |
FTP_PASSWORD |
FTP password |
FTP_REMOTE_PATH |
Remote directory relative to the FTP account home — must end with / (see InterWorx paths below; e.g. example.com/html/) |
| Variable | Description |
|---|---|
SITE_URL |
Pass as site_url input (vars.SITE_URL in the caller). Used only for the workflow summary link text — FTP deploy does not require it. |
Add workflow_dispatch and forward booleans using string comparisons (github.event.inputs values are strings):
on:
workflow_dispatch:
inputs:
dry_run:
type: boolean
default: false
clean_deploy:
type: boolean
default: false
jobs:
deploy-site:
uses: edenia/crservers-static-deploy/.github/workflows/deploy-static-site.yml@v1
secrets:
FTP_HOST: ${{ secrets.FTP_HOST }}
FTP_USER: ${{ secrets.FTP_USER }}
FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }}
FTP_REMOTE_PATH: ${{ secrets.FTP_REMOTE_PATH }}
with:
dry_run: ${{ github.event.inputs.dry_run == 'true' }}
clean_deploy: ${{ github.event.inputs.clean_deploy == 'true' }}
site_url: ${{ vars.SITE_URL }}On push, those comparisons are false because the inputs are absent.
| Input | Default | Notes |
|---|---|---|
node_version |
22 |
Node used for pnpm install / pnpm build on the runner |
install_command |
pnpm install --frozen-lockfile |
Trusted maintainer input |
build_command |
pnpm build |
Trusted maintainer input |
verify_command |
pnpm run verify:static-out |
Skipped if skip_verify: true |
skip_verify |
false |
|
artifact_name |
static-out |
|
artifact_path |
out/ |
|
artifact_retention_days |
7 |
|
ftp_protocol |
ftps |
|
ftp_local_dir |
./out/ |
Must end with / |
ftp_timeout_ms |
1200000 |
|
dry_run |
false |
FTP no-op |
clean_deploy |
false |
Wipes remote FTP_REMOTE_PATH |
site_url |
(empty) | Pass vars.SITE_URL from caller for summary |
Use this when onboarding a new static or Next.js customer repository.
- Next.js static export — in
next.config(ornext.config.mjs):output: 'export'images: { unoptimized: true }if the app usesnext/image
package.json—packageManager(matchespnpm-lock.yaml),verify:static-out(e.g.test -f out/index.html)public/.htaccess— Apache rules for shared hosting (see below)- Caller workflow —
.github/workflows/deploy-static-site.ymlcallingedenia/crservers-static-deploy/.../deploy-static-site.yml@v1with FTP secrets mapped explicitly - GitHub Actions — secrets
FTP_*; optional variableSITE_URL(public URL for deploy summaries only) - First deploy — run workflow with dry_run once, then production; confirm the live site (not only a green Actions run)
- Optional contact form — copy
static-site-contact/intopublic/(or pre-provision on the server), configure SMTP perUSERS-EASY-START.md
On InterWorx shared hosting, the FTP user is usually chrooted to the account home (e.g. /home/ACCOUNT/). FTP-Deploy-Action treats FTP_REMOTE_PATH as relative to that FTP root, not as an absolute path on the server.
FTP_REMOTE_PATH value |
Result |
|---|---|
example.com/html/ |
Correct — files land in /home/ACCOUNT/example.com/html/ |
/home/ACCOUNT/example.com/html/ |
Wrong — uploads often go outside the tree you see over SSH; Actions may still succeed |
/home/ACCOUNT/html/ |
Wrong for the primary domain — often the account default “Test Page”, not the domain vhost |
Rule: Set FTP_REMOTE_PATH to the domain’s web root relative to the account home, with a trailing slash. Confirm in SiteWorx (domain → home / document root) or on the server:
# SSH (paths on disk)
ls /home/ACCOUNT/DOMAIN/html/
# After a successful deploy you should see at least:
# index.html _next/ .htaccess/home/ACCOUNT/ ← FTP login root
├── html/ ← account default page (crservers “Test Page”) — usually NOT the live domain
└── DOMAIN/ ← e.g. example.com/
├── html/ ← document root for https://DOMAIN/ ← deploy here
└── iworx-backup/
Some accounts use domains/DOMAIN/html/ instead of DOMAIN/html/. Always take the path from SiteWorx or ls on the server, then express it relative to /home/ACCOUNT/ in the secret.
| Symptom | Likely cause |
|---|---|
| Actions deploy succeeds; live URL still shows crservers “Test Page” | Wrong FTP_REMOTE_PATH (wrong folder or absolute path) |
SSH into …/DOMAIN/html/ shows only placeholder files (crservers-logo.*, old index.html) |
Deploy never hit that directory — fix path and redeploy |
find /home/ACCOUNT -name '_next' -type d finds _next under html/ or a nested home/… path |
Stray upload from an absolute path — safe to delete after fixing the secret |
Verify the correct folder: after deploy, index.html in the domain html/ should be small (static export) and include _next/. Check the public URL Last-Modified or page title changes.
Verify the public site: curl -sI https://DOMAIN/ | grep -i last-modified and confirm content matches the app (not the default hosting page).
Canonical remote:
git@github.com:edenia/crservers-static-deploy.git
From your local clone of this directory:
cd crservers-static-deploy
git remote add origin git@github.com:edenia/crservers-static-deploy.git # skip if origin already set
git branch -M main
git add -A && git status
git commit -am "crservers.com static site deploy reusable workflow" # if you have local changes
git push -u origin main
git tag v1 && git push origin v1If GitHub created a first commit (for example a default README.md) and git push is rejected, run git fetch origin and either merge with git pull origin main --allow-unrelated-histories and resolve conflicts, or coordinate with your team before any force push.
cd crservers-static-deploy
git init
git add .
git commit -m "crservers.com static site deploy reusable workflow"
gh repo create edenia/crservers-static-deploy --public --source=. --remote=origin --push
git tag v1 && git push origin v1Use a public repo if customer sites live in other GitHub orgs or accounts; otherwise callers cannot resolve uses: edenia/crservers-static-deploy/... unless you rely on Enterprise or org access you already control.
- GitHub Actions: Node runs only on the runner to install dependencies, run
next build, and uploadout/over FTP. Official actions are pinned to v5+ / FTP-Deploy v4.4+ so they use the Node 24 action runtime (avoids the Node 20 deprecation on GitHub-hosted runners). - crservers (production): Only the static files under your
FTP_REMOTE_PATHare needed — typically Apache servesindex.html, assets, and.htaccess. You do not need Node.js on the hosting account for this setup.
Static exports cannot send mail from the browser alone. For contact / inquiry forms, crservers hosts a small PHP + PHPMailer endpoint that authenticates to an InterWorx mailbox over SMTP.
| Piece | Location |
|---|---|
| Canonical bundle | static-site-contact/ in this repo |
| Edenia ops (rsync / pre-provision) | static-site-contact/README-EDENIA-OPS.md |
| Customer setup | static-site-contact/USERS-EASY-START.md → install-on-server.sh, then ../private/smtp.config.php (or env vars) |
| Front-end contract / v0 prompt | static-site-contact/V0-FORM-PROMPT.md, OPERATOR.txt |
Deploy with the static site: copy contact.php, composer.json, and composer.lock into the app’s public/ so pnpm build places them in out/ and the FTP workflow uploads them to the domain html/ (see InterWorx paths). On the server, run composer install --no-dev once in that directory (or use install-on-server.sh after Edenia pre-copies the bundle).
Secrets: never commit SMTP passwords. Use ~/private/smtp.config.php and/or hosting env vars (SMTP_HOST, SMTP_USER, SMTP_PASSWORD, MAIL_TO, optional TURNSTILE_SECRET). Optional Cloudflare Turnstile for public forms.
Customer repos may mirror the bundle under utils/contact-form/; treat static-site-contact/ here as the source of truth (CANONICAL-SOURCE.txt).
Commit this next to your app as public/.htaccess so it is copied into out/.htaccess on build. Directives are wrapped in IfModule so missing modules do not break the site.
# Static Next export on Apache (crservers / shared hosting)
DirectoryIndex index.html
Options -Indexes
# Compression when the host has mod_deflate (no-op if missing)
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml
AddOutputFilterByType DEFLATE text/css application/javascript application/json
AddOutputFilterByType DEFLATE image/svg+xml
</IfModule>
# Long cache for Next.js fingerprinted assets under /_next/static/
<IfModule mod_headers.c>
<LocationMatch "^/_next/static/">
Header set Cache-Control "public, max-age=31536000, immutable"
</LocationMatch>
</IfModule>
# Expires for common image/font types (complements Cache-Control above)
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/avif "access plus 30 days"
ExpiresByType image/webp "access plus 30 days"
ExpiresByType image/jpeg "access plus 30 days"
ExpiresByType image/png "access plus 30 days"
ExpiresByType image/gif "access plus 30 days"
ExpiresByType image/svg+xml "access plus 30 days"
ExpiresByType font/woff2 "access plus 180 days"
ExpiresByType font/woff "access plus 180 days"
</IfModule>Tag stable commits (for example v1, v1.0.0) and pin customer workflows to that tag instead of @main.
- Do not pass untrusted user input into
install_command,build_command, orverify_command. clean_deploydeletes the entire remoteFTP_REMOTE_PATH; use only for intentional full resets.
MIT (see LICENSE).