mechardo3d
Web app for mechardo3d.xyz - a bilingual (es/en) site
with a blog, a contact form and the DS2000 product pages. Built with Rust,
Axum and Tera,
served behind Caddy in production.
Worth a look
- The sitemap cannot drift.
sitemap.xml and robots.txt are generated
from the routing table and the blog data rather than maintained by hand
(src/sitemap.rs).
- Language negotiation. The cookie wins; otherwise
Accept-Language is
parsed with its q-values. A prefix that is not a supported language is
redirected to the visitor's language instead of being rendered as the default
one (src/language_detection.rs).
- Translations are modular and kept in sync. Every
translations/{lang}/*.json is merged at startup, and a test walks both trees
by key and by array index, so a list that grew in one language only fails the
build (src/translations.rs).
- Static files are not served by a directory server. Request paths are
resolved one segment at a time so they cannot escape the static directory,
with
ETag/If-None-Match on top (src/static_files.rs).
- The CV is never a stale copy.
/{lang}/cv streams the latest release
asset of a private repository, cached in memory, so no PDF lives in this repo
(src/data/resume.rs).
- A template failure is a 500 page, not a panic, and the error text never
reaches the client (
src/pages.rs).
Running it
cargo run # http://localhost:3000
npm install # once, for Tailwind
npm run build:css # static/tailwind.css -> static/style.css
make watch # server + CSS with hot reload (needs watchexec)
Useful targets: make test, make lint (fmt + clippy), make check (both),
make run-image / make stop-image for a local container, make run-prod for
the full docker-compose stack.
The contact form verifies reCAPTCHA before storing a message. For local work
either export a real secret or turn verification off:
RECAPTCHA_DISABLED=true cargo run
Configuration
Everything is read from the environment once at startup; every value has a
default, so cargo run works with nothing set.
| Variable | Default | What it does |
|---|
BIND_ADDR | 0.0.0.0 | Address the server binds to |
PORT | 3000 | Port the server binds to |
BASE_URL | https://mechardo3d.xyz | Public origin used for canonical URLs, JSON-LD and the sitemap |
RUST_LOG | info | Log level (trace/debug/info/warn/error, target=level accepted) |
COOKIE_SECURE | false | Marks the language cookie Secure (on in production) |
TRUST_PROXY_HEADERS | false | Read the client IP from X-Forwarded-For (rightmost, parsed as an IP); turn on only behind a proxy that sets it, e.g. Caddy in production |
HSTS_ENABLED | true | Send Strict-Transport-Security |
CONTENT_SECURITY_POLICY | unset | Sent verbatim as the CSP header when set |
RECAPTCHA_SITE_KEY | public site key | Site key handed to the contact page |
RECAPTCHA_SECRET_KEY | from secrets/recaptcha.env | Secret used to verify submissions |
RECAPTCHA_SECRET_FILE | secrets/recaptcha.env | Fallback file for the secret |
RECAPTCHA_MIN_SCORE | 0.6 | Minimum score accepted from reCAPTCHA v3 |
RECAPTCHA_EXPECTED_HOSTNAME | BASE_URL's host | Hostname a verification response must report; set to localhost to test with a real secret locally |
RECAPTCHA_DISABLED | false | Skips verification - local development only |
CONTACT_RATE_LIMIT_SECS |
Container user
The app container runs as an unprivileged user (app, uid 10001), not root.
./data and ./secrets are bind-mounted from the host
(docker-compose.yml), so their ownership isn't fixed by the image - the host
directories must be readable/writable by uid 10001 before docker compose up:
sudo chown -R 10001 ./data ./secrets
Secrets
secrets/ is not tracked. The production stack reads these files, which have to
exist on the server before docker compose up:
| File | Holds | Used by |
|---|
secrets/recaptcha.env | RECAPTCHA_SECRET_KEY | Contact form verification |
secrets/github.env | GITHUB_TOKEN | The CV download on /me and the report download on the blog |
secrets/plausible-db.env | POSTGRES_PASSWORD | Plausible's Postgres |
secrets/plausible.env | DATABASE_URL plus Plausible's own settings | Plausible |
secrets/mail.env | SENDER_EMAIL, SENDER_PASSWORD | tools/send_email.py |
DATABASE_URL embeds the same password, so the two Plausible files have to
agree:
# secrets/plausible-db.env
POSTGRES_PASSWORD=<password>
# secrets/plausible.env
DATABASE_URL=postgres://postgres:<password>@plausible_db:5432/plausible
Changing POSTGRES_PASSWORD on its own does not change the password of a
database that already exists: Postgres only reads that variable when it
initialises an empty data directory, and db-data is a persistent volume. To
rotate the password without dropping the analytics history, change it in the
running database first, then update both files:
docker compose exec plausible_db \
psql -U postgres -c "ALTER USER postgres WITH PASSWORD '<new password>';"
Routes
| Route | Notes |
|---|
/ | Redirects to the visitor's language |
/{lang} | Home (es, en) |
/{lang}/me, /{lang}/blog, /{lang}/blog/{id} | Pages |
/{lang}/cv | The resume PDF, proxied from the latest release of RESUME_REPO |
/{lang}/tpp | The capstone report PDF, proxied from the latest release of REPORT_REPO |
/{lang}/contact | GET renders the form, POST accepts it |
/{lang}/contact_success | Confirmation |
/{lang}/ds2000, /{lang}/ds2000/terms-of-service, /{lang}/ds2000/privacy-policy | Product |
/health | Liveness probe used by Docker |
/robots.txt, /sitemap.xml | Generated from the routing table and the blog data |
/static/{*path}, /favicon.ico | Static assets, with ETag and Cache-Control |
Unknown paths render a localized 404; paths without a language prefix are
redirected to the visitor's language.
Layout
src/
main.rs router, startup
config.rs environment-driven configuration
state.rs shared state (templates, translations, blog, HTTP client)
extract.rs language extractor and URL helpers
middleware.rs security headers, request logging
pages.rs shared page context, rendering, 404/500
responses.rs HTML response + language cookie
static_files.rs static assets, path checks, caching
sitemap.rs sitemap.xml and robots.txt
rate_limit.rs contact form rate limiting
client_ip.rs proxy-aware client address
routes/ one module per page
data/ blog store (cached) and message store
models/ blog post model and view
translations/{lang}/*.json loaded and merged automatically
templates/ Tera templates
content/ blog post bodies, content/blog/{route}/{lang}.html - not
Tera templates, so a stray `{{`/`{%` in one can't stop
the site from starting
Contributor notes and conventions live in CLAUDE.md.