Deploying a Contact Form on Hugo with PHPMailer via SSH

Static sites don’t run server-side code, but a contact form still needs it. Here’s how I wired up a PHP/PHPMailer contact form on a Hugo site deployed to shared hosting via SSH — and what went wrong along the way.

The Setup

The site is built with Hugo and the output is rsync’d to a cPanel shared host. PHP is available server-side, so the contact form posts to a contact.php file that sits alongside the Hugo output in public_html/.

PHPMailer handles the SMTP send. I vendor the library directly (no Composer) — the files live in static/vendor/phpmailer/ and Hugo copies them into public/ at build time.

Keeping Credentials Out of Git

SMTP credentials should never be committed. The approach:

  • contact.php calls require __DIR__ . '/mail-config.php';
  • mail-config.php defines constants (SMTP_HOST, SMTP_USER, SMTP_PASS, etc.)
  • static/mail-config.php is added to .gitignore
  • static/mail-config.example.php is committed as a reference template

On the server, mail-config.php gets chmod 600 — readable only by the process owner.

Deployment via SSH

The deploy command:

hugo && rsync -avz --exclude='mail-config.php' \
  -e "ssh -p 7822" \
  public/ user@ftp.example.com:~/public_html/ \
  && ssh -p 7822 user@ftp.example.com \
     "chmod 600 ~/public_html/mail-config.php"

Key points:

  • --exclude='mail-config.php' prevents rsync from overwriting the live credentials file
  • chmod 600 is re-applied after each deploy as a safeguard

Debugging SMTP

The credentials in mail-config.php were initially wrong. To diagnose, I uploaded a test script that ran PHPMailer with SMTPDebug = 2 directly on the server:

scp -P 7822 smtp_test.php user@ftp.example.com:/tmp/
ssh -p 7822 user@ftp.example.com "php /tmp/smtp_test.php; rm /tmp/smtp_test.php"

The debug output showed 535 Incorrect authentication data immediately — confirmed it was credentials, not a firewall or TLS issue. Once corrected, the output showed 235 Authentication succeeded and the mail was delivered.

File Permissions

Shared hosting is strict about file permissions. The rules:

Type Permission
Directories 755
Files 644
Sensitive config 600

Hugo copies files from static/ preserving their source permissions. If source files have wrong modes, they end up wrong on the server. Fix once at the source:

find static -type f -exec chmod 644 {} \;
find static -type d -exec chmod 755 {} \;

After that, every hugo build produces correctly-permissioned output and rsync propagates it cleanly — no server-side fixes needed on future deploys.

Two files that need manual attention:

  • .htaccess lives directly in public_html/, not managed by Hugo. Set it once: chmod 644 .htaccess. Apache cannot serve any page if it can’t read this file.
  • mail-config.php is excluded from rsync and must be chmod 600 — the deploy command handles this.

Contact Form: Fetch Instead of Plain POST

The original form used a plain HTML <form action="..."> POST. This works but the browser renders raw JSON on the page when validation fails — the user sees {"ok":false,"errors":[...]} and has to hit Back.

The improved version intercepts the submit with fetch:

  • Client-side validation runs first — obvious errors never hit the server
  • Server errors map back to inline field highlights
  • On success, JS redirects to /{lang}/contact/?sent=1 — a proper thank-you page

One gotcha with Hugo templates: the | jsonify filter wraps a string value in JSON quotes. On a param like /contact.php it produces "/contact.php" — correct. But double-applied it produces "\"/contact.php\"", which is a URL containing literal quote characters. The fetch silently fails. Use plain interpolation instead:

var action = "{{ .Site.Params.contactFormAction }}";

Also add ini_set('display_errors', '0'); at the top of contact.php. If PHP warnings are on, they get prepended to the JSON response body, breaking JSON.parse in the browser.

Summary

Problem Fix
Credentials in git Separate mail-config.php, gitignored, chmod 600
SMTP auth failure Test script on server with SMTPDebug = 2
403 on all pages .htaccess was 700 — set to 644
403 on images static/ source files had wrong permissions — fixed at source
JSON parse error in browser display_errors=1 leaked PHP warnings into response body
“Something went wrong” on submit `