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.phpcallsrequire __DIR__ . '/mail-config.php';mail-config.phpdefines constants (SMTP_HOST,SMTP_USER,SMTP_PASS, etc.)static/mail-config.phpis added to.gitignorestatic/mail-config.example.phpis 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 filechmod 600is 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:
.htaccesslives directly inpublic_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.phpis excluded from rsync and must bechmod 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 | ` |