<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: DacForge</title>
    <description>The latest articles on Forem by DacForge (@dacforge).</description>
    <link>https://forem.com/dacforge</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3885021%2F58236b36-14c0-422c-bd95-960293362c49.png</url>
      <title>Forem: DacForge</title>
      <link>https://forem.com/dacforge</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/dacforge"/>
    <language>en</language>
    <item>
      <title>Self-hosted analytics on Coolify with Umami</title>
      <dc:creator>DacForge</dc:creator>
      <pubDate>Sat, 18 Apr 2026 09:18:59 +0000</pubDate>
      <link>https://forem.com/dacforge/self-hosted-analytics-on-coolify-with-umami-2elb</link>
      <guid>https://forem.com/dacforge/self-hosted-analytics-on-coolify-with-umami-2elb</guid>
      <description>&lt;p&gt;When we launched dacforge.com two days ago we shipped it with no analytics. No Google tag, no Plausible, no anything. The privacy posture on the home page said "we run our own infrastructure"; putting a third-party tracker on the site would have been a visible contradiction on line one.&lt;/p&gt;

&lt;p&gt;Two days in, that stance had already stopped being defensible. The site had traffic we could not explain, referrers we could not trace, and pages whose value we could not compare. "No analytics" is a purist line that works until you need to make a decision, and then you have nothing. The honest move was not to hold the line; it was to pick a tool that would let us keep most of the privacy posture intact and add just enough visibility to act on.&lt;/p&gt;

&lt;p&gt;Today we deployed &lt;a href="https://umami.is" rel="noopener noreferrer"&gt;Umami&lt;/a&gt; on the same Hetzner box that serves this site, behind &lt;code&gt;https://analytics.dacforge.com&lt;/code&gt;, and flipped the tracker on in &lt;code&gt;Base.astro&lt;/code&gt;. Here is what shipped, why Umami over the other four options we evaluated, and the short version of the legal posture on &lt;code&gt;/privacy&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraints
&lt;/h2&gt;

&lt;p&gt;Before tool shopping, we wrote down what would make a choice a yes or a no.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted.&lt;/strong&gt; We cannot sell "run your own infrastructure" to clients while running a third-party tracker on our own site. Fathom and Simple Analytics (SaaS-only) fell out here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookieless.&lt;/strong&gt; A cookie banner on our own site would advertise the same contradiction. Rules out GA4 as configured for most sites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low maintenance.&lt;/strong&gt; A 2-person studio does not want a tool that needs twice-yearly CPU-feature checks, SMTP config, three-database compose files, and UID-999 permission dances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free to run.&lt;/strong&gt; We already pay for the Hetzner box. No monthly SaaS fee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookieless implementation that survives legal review.&lt;/strong&gt; First-party, no cross-site identifier, no cookies, clear disclosure, one-click opt-out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five tools cleared the first filter: Umami, Plausible Community Edition, GA4, GoAccess, and do-nothing. Umami won. The rest of this post is the build, then the short version of why the other four did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build, in five minutes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Coolify one-click
&lt;/h3&gt;

&lt;p&gt;Umami is an official Coolify one-click service in the Analytics category. The template is pinned to &lt;code&gt;ghcr.io/umami-software/umami:3.0.3&lt;/code&gt; with &lt;code&gt;postgres:16-alpine&lt;/code&gt;, exposes port 3000 (which matches Coolify's UI default, so no port override), and uses Coolify's magic env vars to auto-generate the Postgres password and the Umami &lt;code&gt;APP_SECRET&lt;/code&gt;. Practical human input is one field: the public domain.&lt;/p&gt;

&lt;p&gt;From the Coolify UI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resources, New Resource, Service, Umami&lt;/li&gt;
&lt;li&gt;Pick the project and environment&lt;/li&gt;
&lt;li&gt;Set the public domain to &lt;code&gt;analytics.dacforge.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Deploy&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Coolify issues the Let's Encrypt cert via Traefik. Postgres comes up alongside Umami in the same compose. Total elapsed: about five minutes, most of which is DNS propagation.&lt;/p&gt;

&lt;p&gt;Log in to Umami at the public domain with the default &lt;code&gt;admin&lt;/code&gt; / &lt;code&gt;umami&lt;/code&gt; and change the password immediately. Add a website, note the generated &lt;code&gt;data-website-id&lt;/code&gt;, and keep the browser tab open while you wire the script into the site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wiring the tracker into Astro
&lt;/h3&gt;

&lt;p&gt;dacforge.com is a static Astro site rendered to HTML and served by nginx. The tracker is a single &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag in the shared &lt;code&gt;Base.astro&lt;/code&gt; layout, gated behind a public env var so local builds do not report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/layouts/Base.astro
const umamiSrc = import.meta.env.PUBLIC_UMAMI_SRC;
const umamiWebsiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
const umamiOrigin = umamiSrc ? new URL(umamiSrc).origin : null;
---
&amp;lt;head&amp;gt;
  {/* ... existing head ... */}
  {umamiOrigin &amp;amp;&amp;amp; &amp;lt;link rel="preconnect" href={umamiOrigin} crossorigin /&amp;gt;}
  {umamiSrc &amp;amp;&amp;amp; umamiWebsiteId &amp;amp;&amp;amp; (
    &amp;lt;script
      defer
      src={umamiSrc}
      data-website-id={umamiWebsiteId}
      data-do-not-track="true"
    /&amp;gt;
  )}
&amp;lt;/head&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. &lt;code&gt;data-do-not-track="true"&lt;/code&gt; tells Umami's script to short-circuit on any browser that sends Do Not Track or Global Privacy Control. And the env-var gate means &lt;code&gt;npm run dev&lt;/code&gt; on a laptop does not pollute the dashboard with a developer's own pageviews; only the production build, which has &lt;code&gt;PUBLIC_UMAMI_SRC&lt;/code&gt; and &lt;code&gt;PUBLIC_UMAMI_WEBSITE_ID&lt;/code&gt; set in Coolify, actually renders the script.&lt;/p&gt;

&lt;p&gt;Deploy, open the site, open the Umami dashboard, watch the pageview count tick up by one. If it does not, the two usual suspects are a typo in the website ID or an ad blocker on your own browser (uBlock Origin blocks Umami's default script path; the workaround is to serve the script from your own domain, which the Coolify template already handles).&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;/privacy&lt;/code&gt; page
&lt;/h3&gt;

&lt;p&gt;A tracker without a privacy page is table stakes wrong. Before flipping the env vars, we rewrote &lt;code&gt;/privacy&lt;/code&gt; to cover four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What we collect.&lt;/strong&gt; Pageview, referrer, anonymised IP-derived country and city, user agent (device class, browser, OS). No cookies, no cross-site identifier, no profile join.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal basis.&lt;/strong&gt; UK: the Data (Use and Access) Act 2025 statistical-purposes PECR exemption, which commenced on 5 February 2026. EU: GDPR Art. 6(1)(f) legitimate interest, bounded retention, no ad targeting. US: we honour Global Privacy Control and Do Not Track signals at the tracker level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention.&lt;/strong&gt; 13 months. Long enough to do year-over-year comparisons, short enough to bound the data. Umami's default is indefinite; the retention window is our add.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opt-out.&lt;/strong&gt; A visible toggle on the page that sets &lt;code&gt;localStorage.umami.disabled = 1&lt;/code&gt; and stops sending events from that browser. One click, no flow, no account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do not claim "no consent required, anywhere." The honest phrasing is "we rely on the UK DUAA statistical-purposes PECR exemption, on GDPR Art. 6(1)(f) legitimate interest in the EU, and we give you a one-click opt-out anyway."&lt;/p&gt;

&lt;h2&gt;
  
  
  What we ruled out
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Plausible Community Edition.&lt;/strong&gt; Shortest distance to a yes of any of the alternatives. It is cookieless, self-hostable, respected in the category. Three things tipped us away. AGPLv3's network-use clause is stricter than MIT for a studio that may fork or patch for client work. Practical RAM floor is ~1 GB with 2 GB recommended (ClickHouse is the cost) against Umami's ~512 MB; on a single Hetzner VPS that matters. And Plausible CE ships "twice annually" by explicit policy, gating funnels, SSO, Sites API, and advanced bot filtering to the cloud product. Coolify also does not ship a one-click for Plausible because of a trademark restriction, which pushes deploy time from five minutes to an hour of compose, SMTP, and port-override work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GA4.&lt;/strong&gt; Off the table for a privacy-positioned studio regardless of tooling. Third-party cookies, data leaves your infrastructure, consent banner required for most of the EU, and the regulatory blast radius is non-trivial. California's Capital One action in May 2025 (around $350k) targeted third-party pixels behind a broken consent UI; first-party cookieless counters on a site honouring Global Privacy Control are not the same category, but the GA-shaped risk is not one we are interested in carrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GoAccess.&lt;/strong&gt; A log-based analyzer that reads nginx access logs and produces an HTML dashboard. Zero privacy cost, no client JS, no database. The reason it did not win is that it cannot see what a client-side tracker sees: no engagement time, no event funnels, no bounce-versus-read distinction. And it adds its own ops cost: a bind-mount from the nginx container, logrotate to coordinate with the real-time HTML refresh. GoAccess stays on the shortlist as a future sidecar for bot and bandwidth visibility, not a replacement for Umami.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do-nothing.&lt;/strong&gt; The version where we literally know nothing about visitor behaviour. Purest privacy posture available, and untenable past launch. We could not make page-investment or content decisions from zero data, and "no analytics" became the thing blocking useful ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want the same setup
&lt;/h2&gt;

&lt;p&gt;The whole stack is one Hetzner box running &lt;a href="https://coolify.io" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt;, with nginx-served dacforge.com and Umami on the same host, Let's Encrypt via Traefik, Postgres per app. If you want a walk-through for your own site we offer a &lt;a href="https://dacforge.com/services/coolify-setup-consultant/" rel="noopener noreferrer"&gt;Coolify setup consultant&lt;/a&gt; engagement that covers the same ground end to end, and a &lt;a href="https://dacforge.com/services/devops-consultant-for-small-business/" rel="noopener noreferrer"&gt;DevOps consultant for small business&lt;/a&gt; engagement for the broader infra picture (boring infrastructure is a feature; it should not be somebody's side quest).&lt;/p&gt;

&lt;p&gt;Or email &lt;a href="mailto:hello@dacforge.com"&gt;hello@dacforge.com&lt;/a&gt; if you want to talk about shipping something together.&lt;/p&gt;

</description>
      <category>umami</category>
      <category>coolify</category>
      <category>astro</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Per-page OG images on an Astro site, without the SaaS</title>
      <dc:creator>DacForge</dc:creator>
      <pubDate>Fri, 17 Apr 2026 22:38:30 +0000</pubDate>
      <link>https://forem.com/dacforge/per-page-og-images-on-an-astro-site-without-the-saas-54ak</link>
      <guid>https://forem.com/dacforge/per-page-og-images-on-an-astro-site-without-the-saas-54ak</guid>
      <description>&lt;p&gt;Every page on dacforge.com now has its own Open Graph image. The services hub has one image. Each of the ten service pages has its own. Each blog post has its own. The about page, the privacy page, and every other public URL on the site has a distinct card that shows up when someone pastes the link into LinkedIn, X, Mastodon, or Slack.&lt;/p&gt;

&lt;p&gt;There is no Cloudinary account. No Vercel OG. No external image service. The whole thing runs at &lt;code&gt;astro build&lt;/code&gt;, writes PNGs into &lt;code&gt;dist/og/&lt;/code&gt;, and that is the last anyone thinks about it until a new page is added, at which point it just works.&lt;/p&gt;

&lt;p&gt;Here is what the setup looks like and why it ended up smaller and more satisfying than any of the SaaS options we considered first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why per-page OG matters at all
&lt;/h2&gt;

&lt;p&gt;If you do not care about the card that appears when someone shares your page in a messaging app or on social, you can skip this paragraph. If you do, consider what the alternative looks like. A single &lt;code&gt;og-image.png&lt;/code&gt; at the root of your site means every URL you share shows the same card. The services page and the about page and the twelfth blog post all look identical. Anyone seeing three of your links in a feed immediately reads the repetition as "this person has a low bar for the surface of their work." Fixing it is the single biggest polish pass you can do on a static site in an afternoon.&lt;/p&gt;

&lt;p&gt;The usual answer is one of three things: hand-design a PNG per page and remember to regenerate it every time the page changes, reach for Cloudinary's dynamic image transformations, or reach for Vercel OG. The first does not scale. The other two work, and they also add a third-party dependency that we are not sure we need for something this simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four cards from the generator
&lt;/h2&gt;

&lt;p&gt;Here are four of the cards the system produced on this site. Same template across all of them; each pulls its title, description, and eyebrow from the content collection the page belongs to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdacforge.com%2Fog%2Fhome.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdacforge.com%2Fog%2Fhome.png" alt="OG card for the DacForge homepage: A SOFTWARE STUDIO eyebrow, headline 'DacForge | Software Studio for Teams That Need to Ship' with the last word in vermillion, subtitle describing the studio." width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;Homepage · A SOFTWARE STUDIO eyebrow
  &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdacforge.com%2Fog%2Fservices%2Fcoolify-setup-consultant.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdacforge.com%2Fog%2Fservices%2Fcoolify-setup-consultant.png" alt="OG card for the Coolify Setup Consultant service page: TOOLING &amp;amp; AUTOMATION eyebrow, short headline 'Coolify Setup Consultant' wrapping to two lines with the last word in vermillion." width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;Coolify Setup Consultant · TOOLING &amp;amp; AUTOMATION eyebrow · short title, one word per line
  &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdacforge.com%2Fog%2Fservices%2Fcustom-software-consultancy-for-small-business.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdacforge.com%2Fog%2Fservices%2Fcustom-software-consultancy-for-small-business.png" alt="OG card for the Custom Software Consultancy for Small Business service page: BUILD eyebrow, long headline wrapping to two lines at smaller type size with 'Business' in vermillion." width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;Custom Software Consultancy · BUILD eyebrow · longer title, two-line wrap, smaller type
  &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdacforge.com%2Fog%2Fblog%2Fcoolify-setup-lessons.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdacforge.com%2Fog%2Fblog%2Fcoolify-setup-lessons.png" alt="OG card for the Coolify setup lessons blog post: BLOG eyebrow, headline 'Coolify setup lessons from shipping this site' wrapping to two lines with 'site' in vermillion." width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;Coolify setup lessons · BLOG eyebrow · blog post card
  &lt;/p&gt;

&lt;p&gt;The eyebrow changes by section. The type size scales with the title length. The last word of the title gets rendered in the brand's vermillion accent (&lt;code&gt;#ef5a38&lt;/code&gt;) on every card. None of this requires per-page configuration: the logic sits entirely in the renderer, and it reads from the site's existing content collection entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build-time approach
&lt;/h2&gt;

&lt;p&gt;DacForge is a studio that spends a lot of time arguing for self-hosted infrastructure for small teams. Pulling in a SaaS to draw the equivalent of a page title on a rectangle would be slightly embarrassing. The constraints we set were straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generate every OG image at &lt;code&gt;astro build&lt;/code&gt; time, not at runtime&lt;/li&gt;
&lt;li&gt;No network call during the build&lt;/li&gt;
&lt;li&gt;Fonts bundled in the repository so output is identical on any build host&lt;/li&gt;
&lt;li&gt;One shared editorial template, so adding a new page requires no per-page manual work&lt;/li&gt;
&lt;li&gt;Small enough in code size that it does not need a dedicated maintainer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; already supports dynamic endpoints that return binary responses. The Astro ecosystem also ships &lt;a href="https://github.com/yisibl/resvg-js" rel="noopener noreferrer"&gt;&lt;code&gt;@resvg/resvg-js&lt;/code&gt;&lt;/a&gt;, a native rasteriser for SVG-to-PNG. Between those two things, every piece we need is already in the toolbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint
&lt;/h2&gt;

&lt;p&gt;The whole system fits into one dynamic Astro endpoint. It lives at &lt;code&gt;src/pages/og/[...path].png.ts&lt;/code&gt;. Its job is to enumerate every page that should have an OG image, then render one PNG per page at build time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GetStaticPaths&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getCollection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;renderOgPng&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../lib/og-image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getStaticPaths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GetStaticPaths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;llm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;$/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;section&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;section&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;renderOgPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;section&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;png&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cache-control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public, max-age=3600&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Astro's &lt;code&gt;getStaticPaths&lt;/code&gt; runs at build time. Each returned entry becomes a route. Each route writes a PNG into &lt;code&gt;dist/og/{slug}.png&lt;/code&gt;. The browser never sees a template, an SVG, or a missing font. It gets a static image file from nginx, same as any other public asset.&lt;/p&gt;

&lt;p&gt;The list of URLs is pulled from the existing content collections. Our &lt;code&gt;llm&lt;/code&gt; collection holds the markdown mirrors of every public HTML page (a separate LLM-accessibility feature we already ship). Blog posts live in a separate &lt;code&gt;posts&lt;/code&gt; collection. Together they cover every URL that should have a card.&lt;/p&gt;

&lt;h2&gt;
  
  
  The template
&lt;/h2&gt;

&lt;p&gt;The rendering helper sits in &lt;code&gt;src/lib/og-image.ts&lt;/code&gt; and has exactly one responsibility: given a page's title, description, and section, produce a PNG. The shape of the design is identical to the rest of the DacForge surface: a dark background, Instrument Serif for the display title with the last word in vermillion, JetBrains Mono for the eyebrow, a thin rule at the footer, the brand wordmark and URL anchored at the bottom corners. Nothing on the card is decorative; every element has a job.&lt;/p&gt;

&lt;p&gt;Two things deserve a mention.&lt;/p&gt;

&lt;p&gt;First, fonts are bundled in the repository and passed to &lt;code&gt;@resvg/resvg-js&lt;/code&gt; via &lt;code&gt;fontFiles&lt;/code&gt;. That makes the output font-independent. Whatever the build host has installed system-wide is ignored. This matters the first time you try to debug an OG image that looks different on your laptop versus in CI and realise that DejaVu Serif was quietly filling in where Instrument Serif should have been. With fonts pinned to the repo, the card is deterministic.&lt;/p&gt;

&lt;p&gt;Second, the template picks a font size based on the title's length. Short titles (&lt;code&gt;Coolify Setup Consultant&lt;/code&gt;) render at 140pt on one line. Longer titles (&lt;code&gt;Custom Software Consultancy for Small Business&lt;/code&gt;) step down to 110pt and wrap to two. The longest edge cases step down again. The card never blows out its frame, never crunches against the footer rule, and the typography stays at visible weight at all thumbnail sizes.&lt;/p&gt;

&lt;p&gt;The last word of the title gets rendered in the brand's vermillion accent (&lt;code&gt;#ef5a38&lt;/code&gt;). This is the same move the DacForge homepage makes with the word &lt;code&gt;software&lt;/code&gt; in the &lt;code&gt;We build software&lt;/code&gt; tagline. Reusing that one design primitive makes every OG card on the site feel like it belongs to the same family without any per-page design effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooking it into the layout
&lt;/h2&gt;

&lt;p&gt;The final piece is a one-line change in &lt;code&gt;Base.astro&lt;/code&gt;, the shared layout for every page. Instead of hardcoding &lt;code&gt;/og-image.png&lt;/code&gt; as the default meta image, the layout derives the per-page URL from the current pathname:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pathToOgSlug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../lib/og-image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;derivedOgPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/og/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;pathToOgSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ogImageURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ogImage&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;derivedOgPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pathToOgSlug&lt;/code&gt; is five lines of string manipulation: strip leading and trailing slashes, treat the root as &lt;code&gt;home&lt;/code&gt;, return everything else as-is. If a page passes an explicit &lt;code&gt;ogImage&lt;/code&gt; prop, that wins. Otherwise the derivation takes over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs
&lt;/h2&gt;

&lt;p&gt;This is not the best approach for every site. A few honest caveats:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One template.&lt;/strong&gt; We do not have per-page custom art. If your site needs genuinely hand-crafted imagery on each card, you will hit the limit of the shared design quickly. For a studio site that values consistency over expressive variation, that is a feature. For a magazine or a gallery, it is not.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build time adds up.&lt;/strong&gt; Rendering 18 PNGs at build takes about a second on this site. At several hundred pages the cost starts to matter. If you are shipping a large content site, caching at the build layer (or rendering only the pages that changed since the last build) is worth setting up.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fonts in the repo.&lt;/strong&gt; Instrument Serif, Inter, and JetBrains Mono live under &lt;code&gt;scripts/fonts/&lt;/code&gt; in the dacforge.com repo. That is a modest size increase (a few hundred KB) that we already accepted when we built the original static OG generator. If your site does not already bundle fonts, this is new surface area for you.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No runtime regeneration.&lt;/strong&gt; Change a page title, you rebuild. For a static Astro site deployed through a normal CI flow, this is what happens anyway. For a CMS-driven setup where content changes without a rebuild, a runtime endpoint is a better shape.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why not SaaS
&lt;/h2&gt;

&lt;p&gt;Cloudinary and Vercel OG both work. For this one, the argument against them comes down to scope: we are drawing a page title on a rectangle. Adding a third-party dependency, an API key, a quota, and a new surface to monitor for a job that a short TypeScript file and a bundled font can handle is a mismatch. The self-hosted route also matches DacForge's general stance toward small teams: fewer moving parts, documented on your own disk, operable without calling someone else.&lt;/p&gt;

&lt;p&gt;None of this is an argument against using a SaaS when it genuinely saves work. It is an argument for checking whether you actually need one before signing up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;p&gt;If you are building on Astro and want the same shape, the source for this setup lives in the dacforge.com repository. The two files that matter are &lt;code&gt;src/pages/og/[...path].png.ts&lt;/code&gt; and &lt;code&gt;src/lib/og-image.ts&lt;/code&gt;. The rest is taste.&lt;/p&gt;

&lt;p&gt;If you are running into self-hosted infrastructure questions more broadly, we offer a &lt;a href="https://dacforge.com/services/coolify-setup-consultant/" rel="noopener noreferrer"&gt;Coolify setup consultant&lt;/a&gt; engagement for teams that want production Coolify deployed properly, and a &lt;a href="https://dacforge.com/services/devops-consultant-for-small-business/" rel="noopener noreferrer"&gt;DevOps consultant for small business&lt;/a&gt; engagement for the broader infra picture.&lt;/p&gt;

&lt;p&gt;Or email &lt;a href="mailto:hello@dacforge.com"&gt;hello@dacforge.com&lt;/a&gt; if you want to talk about shipping something together.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
