37 KiB
Deploying catcrafts.net
Two artifacts come out of CI and land in two different places, on purpose.
| goes to | served by | |
|---|---|---|
| wasm bundle + static assets | /srv/catcrafts.net |
Caddy file_server |
catcrafts-server + content/ |
/srv/catcrafts-app |
itself, on 127.0.0.1:8081 |
They are kept apart because the web root is publicly served and mirrored with
rsync --delete on every push. Anything runtime-owned that lives there is
both published to the internet and destroyed on the next deploy — which matters
now for the content files and matters a great deal once the shop has a database
and bank credentials.
Runtime state goes in a third place, /var/lib/catcrafts, created by the
service's StateDirectory=. Today that is orders.jsonl (the order event log),
orders.jsonl.shipping.json (the cached carrier rate table) and
orders.jsonl.financials.json (the published running totals behind the public
/financials page — see "The open financials page"). Neither payment provider
needs stored state — both authenticate with a bearer token per request.
Two things on this box cannot be regenerated. Everything else — the wasm bundle, the content, the binary — comes back from a rebuild.
The first is orders.jsonl. It is the ledger: every order and every status
transition, append-only, and the audit trail the tax records lean on. Back it
up:
install -d -m 0700 /var/backups/catcrafts
cp /var/lib/catcrafts/orders.jsonl \
/var/backups/catcrafts/orders-$(date -u +%F).jsonl
It contains names, addresses and email addresses, so it is personal data: keep it 0600, keep it off the web root, and encrypt it before it leaves the machine. (The cached shipping table is deliberately NOT worth backing up: delete it and the next Sendcloud refresh rebuilds it. The financials aggregates file is a few hundred bytes the owner's tooling rewrites — keep a copy with the ledger backup all the same, since nothing on this box can regenerate it.)
The second is /srv/catcrafts-app/media — the mirrored post images and screen
recordings. Usually reproducible from content/posts.json, but not if a source
instance has deleted the file since, and for these posts the media is the
content. It lives on the app mount rather than the web root specifically so
rsync --delete cannot reach it, and CI only ever adds to it. Worth including in
the same backup.
Why the backend speaks plaintext HTTP/1.1
Caddy terminates TLS and reverse-proxies to loopback. The backend uses
Crafter.Network's ListenerHTTP1 rather than its HTTP/3 ListenerHTTP for one
concrete reason: Caddy cannot reverse_proxy to an h3 upstream. Port 8081
must never be exposed directly.
One-time host setup
# 1. service account, no login, no home
useradd --system --no-create-home --shell /usr/sbin/nologin catcrafts
# 2. directories
mkdir -p /srv/catcrafts.net /srv/catcrafts-app
chown catcrafts:catcrafts /srv/catcrafts-app
# The web root stays writable by whatever uid the runner container uses.
# 3. units
cp deploy/catcrafts-server.service /etc/systemd/system/
cp deploy/catcrafts-deploy.service /etc/systemd/system/
cp deploy/catcrafts-deploy.path /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now catcrafts-deploy.path
# catcrafts-server is started by the first deploy; enable it so it survives a
# reboot:
systemctl enable catcrafts-server
# 4. Caddy — merge deploy/Caddyfile.example into your site config
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
Runner configuration
Both mounts are required. In the Forgejo runner's config.yaml:
container:
options: "-v /srv/catcrafts.net:/deploy -v /srv/catcrafts-app:/deploy-app"
The deploy steps fail with an explanatory message if either is missing, rather than silently succeeding into the container's own filesystem.
How the restart happens
CI runs inside a container and has no access to the host's systemd. Rather
than give the runner host root or a polkit rule — both of which grant far more
authority than "restart one service" needs — the last deploy step writes
/srv/catcrafts-app/.deploy-stamp, and catcrafts-deploy.path on the host
reacts.
catcrafts-deploy.service runs catcrafts-server --render / as ExecStartPre
before restarting. The unit tests already gated the deploy in CI
(crafter-build test --product=server); this on-host check covers what CI
cannot — the dynamic loader finding libmsquic on this machine and a page
rendering from the deployed content — so a broken binary leaves the working
server running instead of replacing it.
The binary is installed as catcrafts-server.new and mvd into place, so a
request arriving mid-copy never hits a truncated executable.
If the backend is down
Caddy's handle_errors fallback serves the static index.html,
so the site degrades to the client-rendered wasm app rather than showing a 502.
Content still renders; what is lost is server-side rendering and real status
codes — an unknown path becomes a soft 404 again until the backend returns.
Posts and their media
content/posts-sources.json holds the account and a community allowlist.
Only listed communities are fetched, so joining a new one does not silently
publish it to the site — add a line first.
tools/publish-media.sh FILE # BEFORE posting: uploads to /media, prints the URL
tools/fetch-posts.sh # writes content/posts.json
tools/fetch-media.sh # mirrors the media, rewrites posts.json to /media/... paths
Order matters: the second reads what the first wrote. CI runs both before the
build. Neither fails the build on a network error — a fediverse outage leaves the
previous posts.json in place, and a single failed download leaves that one entry
pointing at its original URL rather than losing the post.
The full body, and its inline media
Each post is hosted whole at /posts/<slug>, with the card on /posts linking
to it — the writing is what the site is about, and a page this site can name as
canonical is the only version a search engine can be pointed at. The comments
are not mirrored: every post page links out to the thread, which is where the
discussion belongs.
The body stays Markdown in posts.json and is rendered by
Catcrafts.Shared:Markdown at page-render time, never converted to HTML by the
shell. That is deliberate: the renderer is inside the escaping guarantee, and
text fetched from someone else's server must not be able to become markup
anywhere else. Raw HTML in a body is always shown as text.
fetch-media.sh mirrors what the body embeds as well as the headline file, and
rewrites the URLs inside the Markdown, so a post page loads nothing
third-party either. It also writes a body_media list per post — dimensions,
poster frame and format renditions for each inline file, which Markdown syntax
has nowhere to carry. Slugs come from the title; a duplicate title takes the
post's numeric id as a suffix, so an old post's URL is never renumbered by a new
one. Re-running the script is a no-op: local paths are adopted from the mount
rather than re-fetched.
The image format ladder
Every mirrored still image is transcoded to two siblings named after its content
hash, and Catcrafts.Shared:Media serves all three as one <picture> — so the
browser fetches exactly one:
| tier | file | size vs. WebP | who gets it |
|---|---|---|---|
<source type="image/avif"> |
<hash>.avif |
76% | almost everyone |
<source type="image/webp"> |
<hash>.webp (the mirrored original) |
100% | Safari 14–16 |
<img src> |
<hash>.png |
875% | neither of the above |
The middle tier is why the PNG being ~9× the WebP does not matter: it is free (the mirror already downloaded that file) and it is what the small number of non-AVIF browsers actually land on. The PNG is the floor nothing can refuse.
AVIF is encoded at crf 26, cpu-used 6, yuv444p — measured at SSIM 0.997
against the source and still smaller than it. Full chroma is deliberate: these
are screenshots of text, and re-subsampling chroma that pict-rs already
subsampled once fringes coloured text visibly, for about 3% more bytes.
Encoding is skipped when the sibling is already on the mount, so only genuinely
new images cost encoder time (~0.5 s each). Animated sources are left alone
entirely — one moving GIF beats three copies of its first frame. Video posters
are skipped too: poster takes exactly one URL, so a <video> cannot negotiate
a format the way <picture> can and the renditions would be unreachable.
Both encodes pin -c:v and then verify the codec that actually came out.
That check earned its place immediately: -f image2 out.png without an explicit
codec makes ffmpeg fall back to the muxer default, which is MJPEG — it silently
produced a full set of lossy JPEGs under .png names, served to browsers as
image/png. A rendition that fails the check is discarded and its tier dropped.
Publish the media first, then post it
The recommended flow is to put a recording on catcrafts.net before writing the
post, and use that URL as the post's link. Run tools/publish-media.sh recording.mp4; it transcodes to AV1 and an H.264 sibling, uploads both to the
media mount under the AV1's content hash, and prints a
https://catcrafts.net/media/<hash>.h264.mp4 URL to paste into the post. The
H.264 one on purpose: a post's link is fetched raw — Lemmy apps and browsers play
that exact file, with no negotiation in front of it — so it must be the encoding
everything can play. (An AV1 link posted before this existed drew "bad media
error" reports from iPhones within hours.)
fetch-media.sh recognises its own origin and adopts such a URL: it rewrites
it to /media/<hash> and probes the local file for dimensions, downloading
nothing. That is not just an optimisation — it removes the whole class of build
failure where the media step depends on a third party. A 167 MB recording on a
file host once hit MAX_BYTES (64 MB), so the entry kept its original URL, and
the deploy then failed the "media origin" check (ShouldServePostPages) on a file that was sitting on
our own disk the entire time.
Adopting a <hash>.h264.mp4 URL swaps the AV1 sibling back in as the page's
primary <source> when it is on the mount, keeping the H.264 as the fallback
<source>. So the post links the compatible file, while browsers that can take
AV1 download the small one — the codecs parameter on the first source is what
lets the rest skip it.
The transcode matters as much as the hosting. Phone recordings are wildly
oversized for what they show — that same 167 MB clip was 78 s of a dark room at
17 Mbps, and denoising into AV1 gives the same picture in 15 MB. It also bakes
in the rotation: phones record landscape and attach a display matrix, so an
untouched file reports 1920x1080 while playing portrait, and the width/height
attributes then reserve exactly the wrong box. (fetch-media.sh swaps the
dimensions when it sees a quarter-turn matrix, so a straight-from-phone mirror is
correct too — but transcoding means nothing downstream has to know.)
Two things to know about publishing AV1:
- Browsers without AV1 (Safari before 17, Apple hardware older than A17/M3) get
the
<hash>.h264.mp4sibling: on the site via the second<source>, on the fediverse because that sibling is the posted URL.--rawskips the transcode and the fallback both, so a raw AV1 upload recreates the will-not-play problem — use it for files that are already universally playable. - Lemmy's
pict-rswill not generate a thumbnail from an AV1 file, so a self-hosted video usually arrives with noposter.publish-media.shuploads a poster frame beside the video, named<video-hash>.poster.webp, andfetch-media.shfalls back to that sibling when the instance supplied nothing. A thumbnail the instance did provide always wins. (Posting the H.264 URL also means pict-rs can thumbnail it again, so instance thumbnails come back.)
An own-origin URL naming a file that is not on the mount is deliberately left
pointing at its original URL rather than rewritten. That is a post published
without its media, and failing the origin check (ShouldServePostPages) loudly beats shipping a 404
inside a <video> tag.
fetch-media.sh wants ffprobe (Arch: ffmpeg) to read pixel dimensions,
which become the width/height attributes that stop the page reflowing as
several 5 MB recordings arrive. It degrades to no dimensions without it —
so the ShouldServePostPages suite asserts they are present, and a build host
missing the package fails at the test gate instead of quietly shipping a janky
page.
Only the post's main media is used. Lemmy distinguishes the media a post is
(post.url, with post.thumbnail_url as its generated still) from images merely
embedded in the body. Only the former is mirrored — a post whose pictures are
body-only renders as text, which is correct. Videos get the thumbnail as their
poster; without it a preload="metadata" video is a black box until someone
presses play.
Thread links point at the community's instance, not the author's. A post's
ap_id is on the account's own instance, but the discussion is in the community,
which is federated elsewhere and has its own local id for the same thread. So
fetch-posts.sh resolves each ap_id through the community instance's
resolve_object and stores that URL. One request per post, at build time; a
failure falls back to the ap_id, which still reaches a readable copy.
Media is mirrored, not hotlinked. Three reasons: the privacy notice says everything the browser loads comes from catcrafts.net and should stay true; hotlinking would send every visitor's IP to the source instance; and these posts are their media, so a deleted upstream file would gut the page. Filenames are the content hash, which is why the cache lifetime can be a year.
Payments: Mollie and EURC
Checkout offers the buyer two choices, each served by its own rail:
| Choice | Rail | Env var | What the buyer gets |
|---|---|---|---|
bank |
Mollie | MOLLIE_API_KEY |
iDEAL, cards, bank transfer |
crypto |
EURC | EURC_CHAINS |
self-hosted EURC on the configured EVM chains |
The slots are independent. Set one and the form offers only that method; set both and the buyer picks; set neither and checkout answers 503 with an honest message — the whole site still works, degraded rather than down. A mode whose credential is missing is a startup refusal, not a silent downgrade: a checkout that 502s at the last step is worse than one that never offered.
The EURC rail
No processor, no API key, no account anywhere: the buyer sends EURC (Circle's
euro stablecoin, pegged 1:1 — the token amount IS the euro total) to an
address from a pool this shop generated offline, and the server notices by
polling balanceOf over JSON-RPC at a finalized block. The order page is the
payment page. Three pieces of configuration:
-
EURC_CHAINS=/etc/catcrafts/eurc-chains.json— which chains to watch. Selecting this rail IS setting this variable. One EVM address is valid on every chain at once, so the buyer pays on whichever is cheapest; file order is display order — put the cheap chain (Base) first, and itsnote("lowest network fees") is the nudge the buyer sees.{"chains": [ {"name": "base", "rpc": "https://mainnet.base.org", "contract": "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", "chain_id": 8453, "note": "lowest network fees"}, {"name": "ethereum", "rpc": "https://ethereum-rpc.publicnode.com", "contract": "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c", "chain_id": 1} ]}Contract addresses come from Circle's own list (developers.circle.com/stablecoins/eurc-contract-addresses) and nowhere else — matching the CONTRACT, not the ticker, is what makes a fake "EURC" worthless here. EURC exists on Ethereum, Base, Avalanche, Cronos and World Chain (not Arbitrum/Optimism/Polygon/BNB).
-
EURC_POOL(default<orders>.eurc-addresses) — one receiving address per line,#comments allowed. Generated by the wallet at home; the box holds NO key, NO xpub, and can only hand out addresses it was given. Copy addresses, never retype: the server cannot verify the EIP-55 checksum, so a retyped-but-still-hex address is accepted and published. Append only:<pool>.cursoris an index into this file, so reordering re-issues addresses already bound to old orders. Duplicates and malformed lines are startup refusals; exhaustion too — the low-water warning fires at 25 left. -
EURC_WINDOW_HOURS(default 24) — how long an order may sit unpaid. A lapsed window is NOT bounced money: the address stays ours, a late payment still lands there (the server logs exactly this), and it is settled by hand with--mark-paid.
# Mollie keys: dashboard -> Developers -> API keys. Mollie's test mode works
# against the real endpoints — verify the whole flow before swapping in the
# live key. The EURC rail has no key and no test mode: point EURC_CHAINS at a
# testnet chains file (Base Sepolia / Ethereum Sepolia contracts are in
# Circle's list) to rehearse against worthless tokens instead.
install -d -m 0755 /etc/catcrafts
cat > /etc/catcrafts/payments.env <<'ENV'
MOLLIE_API_KEY=test_your-key-here
EURC_CHAINS=/etc/catcrafts/eurc-chains.json
ENV
chmod 0600 /etc/catcrafts/payments.env
systemctl restart catcrafts-server
journalctl -u catcrafts-server | tail # "payments: bank=mollie crypto=eurc"
Mechanics worth knowing:
- The reconciler polls each open order against the rail that issued its
link — the ledger records
pay_choiceper order for exactly this reason. Mollie every 10 s, the EURC rail every 30 s (a finalized block will not arrive faster), both backing off with age. Arriving back on the order page is ignored as evidence by design: only the authenticated poll (Mollie) or the rail's own RPC check (EURC) moves an order to paid, and the paid event records the method (ideal,creditcard,eurc-base, …) in the ledger. - Unpaid orders lapse automatically and the buyer just orders again.
Mollie expires its own payments; the EURC rail closes its window after
EURC_WINDOW_HOURS(24 by default). A lapsed EURC order is NOT bounced money — see above. - The shop holds EURC: crypto revenue sits at the pool addresses until swept, and nothing lands on the bank account until it is sold for euros at an exchange. Bookkeeping note: a EURC sale is still a euro sale on the invoice — EURC is euro-denominated, so there is no rate and no revaluation — but the financials bank-callback never sees it, so crypto sales exist in the ledger only until the sweep's SEPA leg arrives.
- Card money stays disputable for months even after "paid": before shipping a
large or exported order, glance at the
viacolumn in--orders. iDEAL, bank transfers and EURC are final;creditcardis the one with a tail. This is the one real advantage of the crypto rail — no chargebacks — and it matters most on exactly the non-EU orders where cards get declined. - Mollie onboarding reviews the shop: the imprint (KVK, contact address), terms and privacy pages must be real before they approve live payments. They are — and ShouldStayScriptFree fails the build if a PLACEHOLDER marker ever reaches a rendered page again. The EURC rail has no onboarding, no KYB and no account anywhere; that is the reason it exists.
- Accepting crypto for goods does not make this shop a CASP under MiCA — no custody for others, no transfer for third parties, so no licence is required, with or without a processor. Cashing EURC out to the bank goes through an exchange, and THAT party must be a MiCA-authorised CASP (check the ESMA register) — but it stands after the checkout, not in it.
- VAT is unchanged by payment method: the sale is priced and invoiced in euro and taxed on the euro value, whichever rail settled it.
Live payment suites in CI
Every deploy runs both rails against their real counterparts, as a mandatory
gate — a push that cannot create a Mollie test payment or settle a real EURC
transfer on Ethereum Sepolia does not ship. That is a deliberate
caution-over-convenience trade: a public RPC hiccup can fail a deploy (re-run
the workflow), but the rail code can never drift from the live providers
unnoticed. The suites are ShouldCreateMollieTestPayments (create + poll on a
test-mode key; Mollie has no API to mark a test payment paid, so the paid flip
stays with the fake-rail suite and the pre-launch click-through) and
ShouldSettleEurcOnTestnet (a €1 donation paid with 1 real testnet EURC,
settled by the same two-endpoint quorum as mainnet). Locally both skip unless
their variable is exported; in CI a missing secret is a failure, checked as
the workflow's first step.
Two Forgejo secrets feed them (repo → Settings → Actions → Secrets):
MOLLIE_TEST_API_KEY— thetest_key from the Mollie dashboard (Developers → API keys). The suite refuses anything not startingtest_, so the live key can never be pasted in by mistake without the gate saying so.EURC_E2E_PRIVATE_KEY— a throwaway Ethereum Sepolia key (cast wallet new), used ONLY for this: it holds worthless testnet tokens, never mainnet funds. Fund it with testnet EURC at https://faucet.circle.com — network set to Ethereum Sepolia, one claim is ~20 EURC — and Sepolia ETH for gas from the pk910 PoW faucet (https://sepolia-faucet.pk910.de, no account, it mines in a browser tab; one ~0.05 ETH claim covers hundreds of transfers). Each deploy spends 1 EURC plus gas, and the suite's receiving addresses are random — the EURC is gone after the run. When the wallet runs dry the EURC suite fails atcast sendwith the faucet pointers in its output; top up and re-run.
The workflow installs Foundry's cast for the transfer — the server itself
cannot sign transactions, by design. The EURC suite watches block_tag: "latest" (not production's finalized) because Sepolia finality is ~13
minutes; the rail's reorg warning in that suite's server log is expected. To
prove the funded wallet end-to-end before it gates a deploy, run the suite
live once from a dev shell:
EURC_E2E_PRIVATE_KEY=0x... crafter-build test ShouldSettleEurcOnTestnet --product=server
Shipping rates: Sendcloud (REQUIRED to sell)
Sendcloud is the only source of shipping prices. There is no compiled-in fallback table: a country the carrier has no rate for is a country the shop cannot post a parcel to, so checkout refuses it rather than quoting a price that would then have to be refunded or absorbed. The consequence is blunt and intended — with no rate table, every checkout refuses, and the server says so at startup:
shipping: NO RATE TABLE — checkout will refuse every order until Sendcloud answers
# credentials from Sendcloud: Settings -> Integrations -> API
cat >> /etc/catcrafts/payments.env <<'ENV'
SENDCLOUD_PUBLIC_KEY=...
SENDCLOUD_SECRET_KEY=...
SENDCLOUD_METHOD='PostNL Parcels non-EU,DPD Home'
ENV
systemctl restart catcrafts-server
journalctl -u catcrafts-server | grep shipping: # "table refreshed (N countries...)"
SENDCLOUD_METHOD is a comma-separated list of name substrings, merged in
order with the FIRST FILTER to cover a country winning it — put the postal
method first so non-EU destinations get post rates (a courier method that also
covers Norway or Switzerland would otherwise price them at courier rates, €54
instead of €19), and the courier second to fill the EU.
Every method matching a filter is kept, not just the first: Sendcloud lists the
same service once per weight band, so the matches for DPD Home are that
service's ladder. A parcel is priced at the cheapest band that can carry it,
where the weight is the product's boxed unit weight (shipWeightGrams in
Catcrafts.Shared-Content.cppm) times the quantity ordered. That also sets the
quantity ceiling: one order is one parcel, so an order heavier than every band
is refused with the number that would fit, and the buy form's max shows the
best case across destinations.
A method that publishes no max_weight is skipped rather than treated as
unlimited — same principle, no invented numbers.
The cache is the resilience layer
The fetched table is cached next to the orders file (<orders>.shipping.json)
and read at startup whether or not credentials are configured, so a
Sendcloud outage keeps selling at the last known prices. Format is
country -> [[maxWeightGrams, consumerCents], …], prices already VAT-inclusive
(EU rates are grossed up once, at fetch, so the shop nets the carrier's cost).
That also means a hand-written cache file is a complete rate table, which is how
dev and the black-box suites run with no Sendcloud account at all. A cache written by
an older build (flat country -> cents, no weight bands) is ignored on load and
replaced by the next refresh — those numbers were an unknown weight band and
re-serving them would price parcels by guess.
This integration is UNTESTED against the live API until credentials exist — the response parser is covered by the ShouldParseSendcloudRates test, the fetch around it is thin. Verify one real fetch before opening the shop: check that the logged country count and the weight bands match what the Sendcloud panel shows, because that table is now the difference between a shop that sells and one that refuses everything.
The buyer sees whatever the server will charge: the checkout page embeds the active table into its live total (picking the same band, refusing in the same places), and the amount is computed server-side at order time from the same data.
Invoice signing (GPG)
Paid orders offer a clearsigned markdown invoice at /order/<token>/invoice.md.
Numbering continues the pre-shop administration: one series per customer — a
random UUID as the customer number, invoices counting sequentially within it
(f57c6512-…-3), keyed by the buyer's email. Art. 226(2) permits "one or more
series"; completeness is provable by reconciling the append-only ledger against
the payment provider's records. The signature makes the invoice verifiable
forever, independent of this server — which is why the order page tells buyers
to download it rather than promising to host receipts indefinitely.
One-time key setup on the server, as the service user:
sudo -u catcrafts env GNUPGHOME=/var/lib/catcrafts/gnupg \
gpg --batch --passphrase '' --quick-gen-key 'Catcrafts invoices <invoices@catcrafts.net>' default default never
# export the PUBLIC key and commit it to the repo so buyers can verify:
sudo -u catcrafts env GNUPGHOME=/var/lib/catcrafts/gnupg \
gpg --armor --export invoices@catcrafts.net > invoice-key.asc
Then in /etc/catcrafts/payments.env:
INVOICE_GPG_KEY=invoices@catcrafts.net
and Environment=GNUPGHOME=/var/lib/catcrafts/gnupg in the service unit (see
catcrafts-server.service). The key has no passphrase because the service signs
unattended; the keyring lives in the 0700 StateDirectory. With a key configured,
a signing failure is a 500 — an unsigned invoice is never served by accident.
Without one (dev), invoices carry a visible UNSIGNED marker.
Order email (confirmation + invoice)
A paid order gets one confirmation email with the clearsigned invoice
attached — plain text plus a markdown attachment, no HTML part, no remote
resources, nothing the privacy notice would have to explain. The mailer
watches the ledger, so every path to paid (reconciler, arrival poll, a manual
--mark-paid even on a later restart) results in exactly one email: the
notified event, appended only after the mail command accepts the message,
is what stops a resend.
Delivery shells out to a sendmail-compatible command rather than speaking
SMTP itself, for the same reason invoices shell out to gpg: TLS, AUTH and
deliverability are exactly what msmtp already does well, and the volume is a
handful of messages per week. Deliverability stays the mailbox provider's
problem (SPF/DKIM are theirs), and no third party beyond the provider that
already handles info@catcrafts.net ever sees order data — which is what the
privacy page implies.
apt install msmtp
cat > /etc/msmtprc <<'CONF'
defaults
auth on
tls on
tls_starttls on
account catcrafts
host smtp.your-mail-provider.example
port 587
from info@catcrafts.net
user info@catcrafts.net
passwordeval cat /etc/catcrafts/smtp-password
account default : catcrafts
CONF
chmod 0644 /etc/msmtprc
# msmtp runs as the service user, so the password file must be readable by
# it — unlike payments.env, which only root (systemd) reads.
install -o catcrafts -g catcrafts -m 0600 /dev/null /etc/catcrafts/smtp-password
# ...then put the SMTP password in that file.
Then in /etc/catcrafts/payments.env:
MAIL_COMMAND=msmtp -t
MAIL_FROM=Catcrafts <info@catcrafts.net>
and systemctl restart catcrafts-server — the journal should say
mail: order confirmations via 'msmtp -t'. Unset, no email is sent and the
order page plus the invoice download remain the buyer's receipt: degraded,
not down, like every optional integration here.
Worth knowing:
- A failed handoff retries with exponential backoff (1 min doubling to a cap
of ~an hour), forever — a broken relay delays the email, it never eats it.
Watch
journalctl -u catcrafts-server | grep 'mail:'after changing config. - With a signing key configured, a gpg failure means the email WAITS — an unsigned invoice never leaves by accident, same rule as the download.
- Send a real test:
--rail=fakelocally withMAIL_COMMANDpointing at msmtp and your own address in the order form, or just run the ShouldProcessCheckout suite (crafter-build test ShouldProcessCheckout --product=server), which captures the messages with a fake sendmail and verifies the attached signature.
Reading the orders ledger
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl
orders: 2
reference status total cc created token
CC-3F9A2C paid 595.00 NL 2026-08-04T14:02:11Z 3f9a2c…
CC-91B04D awaiting_payment 534.34 CA 2026-08-04T15:40:03Z 91b04d…
Manual transitions exist for the cases automation cannot see — a payment confirmed out-of-band, the parcel handed to the carrier, a refund:
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl --mark-paid <token>
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl --mark-shipped <token>
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl --cancel <token>
Each appends a status event to the log — nothing is ever rewritten, so the file remains its own audit trail. Deleting personal data on request is an edit of the fields the seven-year fiscal retention does not cover.
The open financials page
/financials publishes running totals only: sales, donations, and expenses
as one flat list of categories. Sales — and donations made through the shop —
fold out of orders.jsonl on every request and need no setup at all.
The bank side (donations to the bank account, and the expense categories) is
orders.jsonl.financials.json, written by the owner's own tooling off this
box and re-read on every request: updating the file is all it takes to update
the page. Aggregates by construction — category totals and an as-of date are
all the file can carry, which is the page's privacy design. No bank credential
of any kind lives on this box.
(A bunq mutation callback used to keep these numbers live; it was retired 2026-08-17 — webhook deregistered, endpoint removed. The code and its documentation are in git history if a bank feed ever comes back.)
Verifying a deploy
systemctl status catcrafts-server
curl -s localhost:8081/api/healthz # ok + content counts
# real status codes, which a client-side router cannot produce
curl -o /dev/null -w '%{http_code}\n' https://catcrafts.net/nope # 404
curl -o /dev/null -w '%{http_code}\n' https://catcrafts.net/blog # 301
# the SEO check: content present with no JavaScript involved
curl -s https://catcrafts.net/projects | grep -c '<script' # 0
curl -s https://catcrafts.net/projects | grep -o '<title>[^<]*'
Analytics
Server-side only — the privacy policy promises request logging and nothing else, so there is no client-side analytics anywhere on the site. GoAccess (Debian package) turns Caddy's JSON access logs into two static HTML reports, each with its own persistent DB and ingest ledger:
https://catcrafts.net/analytics/— public, censored. Visitor IPs are anonymized at ingest (last octet zeroed before anything reaches its DB), no HOSTS or full-URL REFERRERS panels, and log lines matchingCENSOR_REin the script never enter its DB at all — the public tier cannot leak what it never ingested.CENSOR_REcovers/apiand/order: an order token is the entire capability to read that buyer's status page and their invoice (name, street, postal code, city), so publishing the path publishes the buyer. Keep secrets out of URL paths regardless — query strings are already stripped, paths are not.https://catcrafts.net/analytics/private/— uncensored (basic auth, hash in the Caddyfile): full IPs, all panels.
Raw logs keep full IPs either way — that is the request logging the privacy policy declares; per-IP forensics work from the logs and the private tier, never from the public page.
Three pieces, all in deploy/:
catcrafts-analytics→/usr/local/bin/— ingests each rotatedcatcrafts.net-*.log.gzexactly once into a persistent GoAccess DB (/var/lib/goaccess/db, tracked in/var/lib/goaccess/ingested), then renders the report from DB + live log. The live file is never persisted, so its lines don't double-count when Caddy rotates it. History therefore survives log deletion: the DB keeps aggregates forever.catcrafts-analytics.service— oneshot, runs ascaddy(owner of the 0600 logs).catcrafts-analytics.timer— hourly at :07.
Bot filtering is the load-bearing part: measured on real traffic, 57% of
requests were headerless vulnerability scanners and another 19% self-declared
bots (mostly ClaudeBot) — only ~24% human. --ignore-crawlers --unknowns-as-crawlers drops both groups. The flags in the script apply at
ingest time and the DB stores aggregated data, so changing filters later only
affects new lines — re-ingesting history means deleting
/var/lib/goaccess/{db,ingested} and letting the next run rebuild from
whatever raw logs retention still holds (a year, per the Caddyfile).
apt install goaccess
install -m 755 deploy/catcrafts-analytics /usr/local/bin/
install -m 644 deploy/catcrafts-analytics.{service,timer} /etc/systemd/system/
mkdir -p /etc/goaccess /var/lib/goaccess /var/www/analytics /var/www/analytics-private
install -m 644 deploy/goaccess-browsers.list /etc/goaccess/browsers.list
# own IPs to keep out of the numbers - host-only file, NOT in this repo
echo "203.0.113.7" > /etc/goaccess/exclude-ips
chown -R caddy:caddy /var/lib/goaccess /var/www/analytics /var/www/analytics-private
systemctl daemon-reload && systemctl enable --now catcrafts-analytics.timer
Running it locally
tools/dev.sh # build both products and serve on :8080
tools/dev.sh --no-build # reuse what is already in bin/
That is the whole site: Caddy in front, the backend behind, static assets from
disk, cross-origin headers scoped exactly as in production. Ctrl-C stops both.
Orders from the session go to a temp file and are discarded on exit; the
fake payment rail is active — touch <workdir>/orders.jsonl.fake-paid plays
the part of the customer paying.
Do not run catcrafts-server --serve alone and expect a working site. It
serves pages only — static assets are Caddy's job — so /styles.css 404s and
every page renders unstyled. That looks broken but isn't; it is the deployment
split working as designed.
Other useful commands
tools/fetch-posts.sh # pull posts from the allowed communities
tools/fetch-media.sh [dir] # mirror their media locally (run after the above)
tools/fetch-rates.sh # ECB reference rates for the indicative prices
crafter-build -r # the wasm app alone, no backend, on :8080
crafter-build test --product=server # every suite: the unit assertions
# AND the black-box HTTP suites, which spawn the
# real server on scratch ports (the CI gate; the
# checkout lifecycle re-arms at launch)
<server>/catcrafts-server --routes # status + title for every route
<server>/catcrafts-server --render /projects # dump one page's HTML
<server>/catcrafts-server --orders FILE # the orders ledger + manual transitions
If a bin/ glob matches two directories
The variant directory name embeds a config hash, and and non- builds hash differently: `` resolves the Crafter libraries
from sibling working trees, a plain build fetches them from Forgejo. They are
genuinely different configurations and both land in bin/.
Mixing them leaves two directories, and any script globbing for one picks
arbitrarily — which in practice means testing a stale binary and believing the
result. Every script here refuses to guess and tells you to rm -rf bin. Pick
one mode and stay in it.