- HTML 93%
- Python 6.1%
- Vim Snippet 0.9%
Single-page site for Dr. Ceylan Çelen (index.html, logo.svg) and a standard-library Python endpoint for the inquiry form (inquiry.py), with its systemd unit and Caddy snippet. Built and tested, not deployed. inquiry.env holds SMTP credentials and is excluded via .gitignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| .gitignore | ||
| Caddyfile.snippet | ||
| handover.md | ||
| index.html | ||
| inquiry.py | ||
| inquiry.service | ||
| logo.svg | ||
| README.md | ||
Practice site: build notes and deployment
Single-page website for Dr. Ceylan Çelen, Clinical Psychologist, plus a self-hosted endpoint for the inquiry form.
Files
| File | Goes where | Purpose |
|---|---|---|
index.html |
web host, site root | The entire site. Self-contained apart from the favicon. |
logo.svg |
web host, site root | Favicon, and the vector master of the logo. Must sit beside index.html. |
inquiry.py |
celen.cloud, /opt/inquiry/ |
The form endpoint. |
inquiry.service |
celen.cloud, /etc/systemd/system/ |
systemd unit for the above. |
inquiry.env |
celen.cloud, /etc/inquiry.env |
SMTP credentials. chmod 600. |
Caddyfile.snippet |
stays on your Mac | Text to paste into the server's Caddyfile. |
The website and the form endpoint are independent. The site can live anywhere; celen.cloud only ever runs the endpoint.
What was built
The logo
The only surviving artwork was a 1400x1510 JPEG. It has been recovered as a clean vector, so you have a usable master again.
The method was color separation followed by tracing. Each pixel was classified to the
nearest of the three logo colors, morphological cleanup removed JPEG ringing, potrace
converted each region to Bezier paths, and the light blue background turned out to be
geometrically a true circle (max radius 663.36px against an area-equivalent radius of
663.03px), so it was replaced with an actual <circle> element rather than a traced
approximation. Result is 16 KB and scales to any size.
Palette, sampled from the original:
#87D9FFlight blue disk#0034CEdeep blue Ç#FFFFFFwhite C
The site
One HTML file, no build step, no dependencies beyond two Google Fonts. The logo paths
are inlined as SVG <defs> and referenced twice with <use>, so the header mark and
the hero mark share one copy of the geometry.
Design notes, in case you want to change something and wonder why it is the way it is:
- The logo is two hands reaching toward each other and not quite touching, forming a C and a Ç. That gap is the organizing idea. Section dividers are two rules that approach and stop short of each other, and the one animation on the page is the white C and the blue Ç sliding in from opposite sides on load.
- Type is Newsreader for display and Karla for body and UI. Both cover Turkish glyphs.
- Colors come from the logo. There is no third accent hue on purpose.
- All motion is inside
@media (prefers-reduced-motion: no-preference). - Copy is verbatim from what you supplied, with one exception noted under Open decisions below.
- JSON-LD structured data at the bottom of the file describes the practice as a
Psychologistfor search engines. Update the address there too if it ever changes.
The form endpoint
80 lines of standard library Python. No framework, no database, no pip install. It
accepts one urlencoded POST, sends an email, and answers {"ok":true}.
Behavior, all of it tested:
| Situation | Response | What the visitor sees |
|---|---|---|
| Valid submission | 200 | Thank you message |
| Honeypot field filled | 200, discarded | Thank you message |
| Missing name, email, or message | 400 | Inline validation notice |
| Body over 20 KB | 413 | Failure notice with email and phone |
| SMTP unreachable | 502 | Failure notice with email and phone |
Two deliberate choices. log_message is overridden to a no-op so nothing a visitor
types reaches journald, and the systemd unit runs under DynamicUser with
ProtectSystem=strict, so a compromise yields a process that can open a socket and
little else.
Deploying the site
- Choose a host and register the domain. Netlify or Cloudflare Pages, drag and drop, about ten minutes including DNS. Keep the site off celen.cloud so that an outage there cannot take down her referral path.
- Upload
index.htmlandlogo.svgtogether into the site root.
Deploying the form endpoint
Roughly 25 minutes plus DNS propagation. Replace you with your login name on the
server, and HER-DOMAIN with the practice site's domain.
1. Copy the files across.
cd ~/Downloads/files
scp inquiry.py inquiry.service inquiry.env you@celen.cloud:/tmp/
ssh you@celen.cloud
sudo mkdir -p /opt/inquiry
sudo mv /tmp/inquiry.py /opt/inquiry/
sudo mv /tmp/inquiry.env /etc/inquiry.env
sudo mv /tmp/inquiry.service /etc/systemd/system/
sudo chmod 600 /etc/inquiry.env
2. Fill in the SMTP credentials.
sudo nano /etc/inquiry.env
Through Gmail that means SMTP_HOST=smtp.gmail.com, SMTP_PORT=465, SMTP_USER set
to the sending address, and SMTP_PASS set to a 16 character app password from
myaccount.google.com/apppasswords. A normal account password will fail login.
INQUIRY_TO is where inquiries arrive and can differ from SMTP_USER.
3. Start the service.
sudo systemctl daemon-reload
sudo systemctl enable --now inquiry
systemctl status inquiry --no-pager
You want active (running).
4. Smoke test before involving DNS or TLS.
curl -i -X POST http://127.0.0.1:8099/ \
--data-urlencode "name=Test" \
--data-urlencode "email=you@example.com" \
--data-urlencode "message=smoke test"
Expect 200, {"ok":true}, and an email that actually lands. On a 502 run
journalctl -u inquiry -n 20, which prints the exception class name and no message
content. Do not move on until mail is arriving. This step isolates SMTP from
everything else.
5. Add the DNS record.
An A record for inquiry.celen.cloud pointing at the server. Confirm with
dig +short inquiry.celen.cloud before continuing, because Caddy cannot issue a
certificate until it resolves.
6. Add the Caddy block.
Paste Caddyfile.snippet into the server's Caddyfile with SITE_ORIGIN replaced by
the practice site's exact origin. Scheme included, no trailing slash, no path, and it
is her domain rather than inquiry.celen.cloud.
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Verify from your Mac:
curl -i -X POST https://inquiry.celen.cloud/ \
-H "Origin: https://HER-DOMAIN" \
--data-urlencode "name=Test" \
--data-urlencode "email=you@example.com" \
--data-urlencode "message=via caddy"
The response headers must include access-control-allow-origin.
7. Point the page at it.
Lines 500 and 501 of index.html:
const FORM_MODE = "endpoint";
const FORM_ENDPOINT = "https://inquiry.celen.cloud/";
Leave line 502 (INBOX) alone. That is the address shown to visitors when a
submission fails, and it applies in every mode.
Redeploy, then submit once through the real form.
Configuration reference
FORM_MODE in index.html takes three values:
"endpoint"posts toFORM_ENDPOINT. Use this forinquiry.py, for an n8n webhook, or for a hosted service like Formspree or Basin. All three speak the same wire format because the page sendsapplication/x-www-form-urlencoded."netlify"posts to/and relies on Netlify Forms. Works only if the site is hosted on Netlify. Currently the default."email"opens the visitor's mail client. Unreliable on phones and webmail. Use it only as a stopgap if you launch the site before the endpoint is up, because otherwise someone will type out a message and then be told it failed.
inquiry.py reads five environment variables from /etc/inquiry.env: INQUIRY_TO,
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS. It also honors PORT, defaulting
to 8099, which is the port the Caddy snippet proxies to.
Troubleshooting
Email arrives but the page says it failed. Missing or mismatched
Access-Control-Allow-Origin. The POST succeeds; the browser refuses to let the
JavaScript read the reply. Check SITE_ORIGIN in the Caddyfile character for
character.
Everything fails when testing locally. A file:// page sends a null origin, which
will never match. Test from the deployed site.
502 with SMTPAuthenticationError. Gmail with an account password instead of an
app password.
502 with a name resolution error. The unit ships with AF_UNIX allowed in
RestrictAddressFamilies precisely to prevent this. If you edited that line, put it
back.
The form works but nothing arrives. Check the spam folder first, then confirm
INQUIRY_TO is what you think it is.
Open decisions
- Hosting and domain. Nothing else can be tested end to end until this exists.
privacy.html. The footer links to it and it does not exist yet. As a registered health service provider she is covered by the Australian Privacy Principles regardless of practice turnover, so this is not optional. What it has to say depends on item 4.- The $158.95 out of pocket figure. Derived as $308 minus $149.05 and standard on
Australian psychology sites, but it is mine rather than hers. Delete the third
.feeblock if she would rather not state it. - The free-text clinical field. "What would you like help with?" invites people to type health information into a form. Self-hosting the endpoint keeps that off third party servers, but it still lands in a Gmail inbox, so the border crossing is relocated rather than removed. Two clean resolutions: disclose it in the privacy policy, or narrow the field to something like "How can I help, and when is a good time to reach you?" and hold the clinical conversation by phone. If data sovereignty is the real goal, moving the practice inbox off Gmail does more than any amount of self-hosted plumbing.