Personal starting point for static Astro sites that need:
- SEO baked in — a reusable
<head>layout, auto-generated sitemap +robots.txt, Open Graph / Twitter cards, and JSON-LD structured data. - A working contact form backed by a small PHP endpoint (no third-party email service).
- Cloudflare Turnstile in place of Google reCAPTCHA.
- A Python script that builds and uploads
dist/over FTP/FTPS to the host.
| Command | Action |
|---|---|
pnpm install |
Install dependencies |
pnpm dev |
Start local dev server at localhost:4321 |
pnpm build |
Build the production site to ./dist/ |
pnpm preview |
Preview the built site locally |
pnpm deploy |
Build and upload dist/ to the configured FTP host |
pnpm deploy:no-build |
Upload the existing dist/ without rebuilding |
public/
contact.php ← POST endpoint (validate, verify Turnstile, mail())
contact.config.php ← gitignored — destination addresses + Turnstile secret
contact.config.example.php ← committed template
og-default.png ← placeholder social-share image (replace with a real 1200×630)
src/
layouts/
BaseLayout.astro ← SEO <head>: meta, canonical, OG, Twitter, JSON-LD, analytics
main.astro ← Markdown/MDX layout — wraps BaseLayout, reads frontmatter
components/
Contact.astro ← generic form: name / email / phone / subject / message
pages/
robots.txt.ts ← generates /robots.txt (Sitemap URL tracks the site domain)
scripts/
contact.ts ← submit handler (fetch, loading, success/error, reset)
prompts/
seo-update.md ← paste into an AI to configure SEO for a new project
scripts/
deploy.py ← Python 3 stdlib FTP/FTPS uploader
Dockerfile ← local-only PHP image (msmtp → mailpit)
docker-compose.yml ← local-only stack: PHP + mailpit catcher
astro.config.mjs ← `site` domain + @astrojs/sitemap integration
.env.example ← Turnstile site key + FTP creds
Every page should render through src/layouts/BaseLayout.astro, which emits a
complete, best-practice <head>:
- Unique
<title>+ metadescription,<link rel="canonical">, and an explicitrobotsdirective (opting into large image/snippet previews;noindexper page). - Open Graph + Twitter Card tags (with image, alt, and optional
@handles). - JSON-LD structured data —
WebSiteby default,Article(with dates/author) when you passtype="article". - Favicons, mobile
theme-color, and privacy-friendly Umami analytics (guarded onPUBLIC_UMAMI_WEBSITE_ID).
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout
title="Custom Leather Bags — Acme Studio"
description="Handmade leather bags, built to last. Free shipping across the US."
siteName="Acme Studio"
>
<main>…</main>
</BaseLayout>Site-wide pieces:
astro.config.mjs → site— set your production domain here first. It drives canonical URLs, OG image URLs,robots.txt, and the sitemap.- Sitemap —
@astrojs/sitemapemits/sitemap-index.xmlat build automatically. robots.txt— generated bysrc/pages/robots.txt.ts; itsSitemap:line always matchessite. AddDisallow:rules there.public/og-default.png— replace the placeholder with a real 1200×630 image.
Paste prompts/seo-update.md into your AI assistant, fill in the project brief
at the top, and it will set the domain, write per-page titles/descriptions, add
structured data, and flag what still needs a human (real share image, social
handles, Search Console verification). See prompts/.
After going live, submit https://yourdomain.com/sitemap-index.xml to
Google Search Console and
Bing Webmaster Tools.
Contact.astro POSTs FormData to /contact.php, which:
- Rejects non-POST requests.
- Drops bots that fill the
_hphoneypot. - Verifies the Turnstile token at
https://challenges.cloudflare.com/turnstile/v0/siteverify. - Validates required fields (
name,email,message) and the email format. - Sanitizes header-injection chars (
\r,\n,\0). - Calls PHP's built-in
mail()— on Hostinger / cPanel hosts this hands the message to the local MTA, which DKIM-signs outbound mail for any domain whose mailboxes live in the same hosting account. No SMTP credentials needed.
To embed: import Contact from '../components/Contact.astro'; and drop <Contact /> in a page.
- Cloudflare Turnstile → dashboard → add site → copy:
- Site key →
.envasPUBLIC_TURNSTILE_SITE_KEY=. Astro bakes it into the build. - Secret key →
public/contact.config.phpasTURNSTILE_SECRET. Gitignored.
- Site key →
- Mail addresses → also in
public/contact.config.php: editMAIL_FROM,MAIL_FROM_NAME,MAIL_TO. - First deploy → upload everything once, then on the host edit
contact.config.phpto put the real values in. Subsequentpnpm deployruns leave it alone.
Open Contact.astro and edit the labels / placeholders objects at the top. To add a field, also update contact.php (validation + body) and the form markup. To remove one, drop it from both ends.
pnpm run deploy runs scripts/deploy.py — Python 3, stdlib only (ftplib, ssl). Reads .env at the project root, builds via pnpm build, opens an FTP/FTPS connection, and uploads everything in dist/ recursively. Never overwrites contact.config.php on the host so the production secret survives redeploys.
.env keys:
FTP_HOST bare hostname or IP (no "ftp://" prefix)
FTP_USER
FTP_PASSWORD
FTP_REMOTE_DIR empty when the FTP account is chrooted to the site root (Hostinger default)
FTP_PORT default 21
FTP_USE_TLS default true (explicit FTPS)
FTP_TLS_VERIFY default true; set false to skip cert hostname check (e.g. when connecting by IP)
Output is colored, per-file, with size and total summary.
astro dev does not execute PHP — submitting the form against the dev server returns 404 / serves .php as text. The repo ships a Docker setup that runs PHP + a fake SMTP catcher so you can exercise the full submit → validate → Turnstile → mail() flow without sending real email or owning a Cloudflare account.
Requires: Docker Desktop (or any Docker engine with the compose plugin).
cp .env.example .env
cp public/contact.config.example.php public/contact.config.phpOpen public/contact.config.php and change MAIL_TO to the address you want to see the test emails arrive at — though for local testing it doesn't really matter, since mailpit catches everything regardless of destination. Nothing else needs to change. Both example files ship with Cloudflare's public "always passes" Turnstile test keys baked in, so the widget renders and the server-side verification succeeds on any hostname (including localhost) with no Cloudflare configuration.
pnpm install
pnpm build
docker compose upThen:
- Form → http://localhost:8080
- Mailpit inbox → http://localhost:8025
Submit the form. Mailpit will show the message instantly with full headers, the rendered body, and source view. Press Ctrl+C to stop, docker compose down to clean up.
docker-compose.yml boots two containers:
php—php:8.2-apachewithmsmtpinstalled.msmtpis a tiny sendmail-compatible binary; PHP'ssendmail_pathis pointed at it, and msmtp is configured to forward to the mailpit container instead of attempting real SMTP delivery. Thedist/folder is bind-mounted as the docroot, so anypnpm buildrerun is reflected immediately.mailpit— captures every message msmtp forwards and exposes a web UI on port 8025. Messages are in-memory; restarting the container clears them.
Both example files default to Cloudflare's test keys for zero-friction local testing. Before your first real deploy:
- Get a free pair of Turnstile keys from Cloudflare Turnstile — sign in, add your site, and Cloudflare hands you a site key + secret key.
- Replace
PUBLIC_TURNSTILE_SITE_KEYin.envwith your real Turnstile site key. - Replace
TURNSTILE_SECRETinpublic/contact.config.phpwith your real Turnstile secret key. - Set the real
MAIL_FROM,MAIL_FROM_NAME, andMAIL_TOinpublic/contact.config.php.
If you only need to confirm the endpoint is reachable and don't care about catching the actual mail, you can skip Docker entirely:
pnpm build
php -S localhost:8080 -t distmail() will silently fail unless your machine has a local MTA configured, but Turnstile verification and form validation still run. Add localhost as a hostname in the Cloudflare Turnstile dashboard if you've swapped the test keys for real ones.