Vibecode Orshot
track this build4 phases, 12 steps, beginner friendly0%The render itself is not the moat. Headless Chromium plus a Handlebars template gets you a PNG or a PDF from JSON in an afternoon, and for one team rendering its own OG images that really is the whole job. What does not fall out of a weekend is everything around the render: a visual editor non engineers can use, one design that re solves cleanly into a story, a square, and an OG card, workflows that fire on a schedule or a webhook against Sheets and Airtable, publishing into 15 plus social platforms, and a render farm that stays warm and predictable under burst. Build it if you own the templates and the volume is small. Keep paying if the people making the creatives do not write HTML.
You are building a lean indie version of Orshot. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Orshot · indie build A template-driven render service whose hard problems are producing the same pixels on every machine, keeping a browser pool warm and bounded under burst, and never letting a caller's data turn into code the browser runs. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One Node process with an HTTP API and a pool of reusable headless Chromium pages | the browser is the renderer and starting one per request is the whole performance story | | Templates | Server-side folders of HTML, CSS and a declared data schema, versioned in the repository | data is data, markup is code, and the caller supplies only the first | | Fonts | Bundled beside the templates, loaded from disk, hashed into the cache key | a system fallback shifts every line and the output still looks plausible | | Output | Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template | the two paths use different CSS media and pretending otherwise gives you a colourless invoice | | Cache | Content hash of template version, payload and font set, on disk with a size ceiling | the same card is asked for a thousand times and should be drawn once | | Isolation | Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution | an open renderer fetches whatever the caller names, including your metadata endpoint | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the service and the browser driver. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Playwright with Chromium and its system libraries** · free - Why: The renderer. Installing the browser without its system libraries is the most common cause of a service that starts cleanly and renders nothing. - Get it: npm i -D playwright then npx playwright install --with-deps chromium. - Verify: A scripted screenshot of a local page produces a non-empty PNG inside the container - [ ] **Licensed font files you may embed** · free with open licences - Why: Bundling a font you have no licence to redistribute is a legal problem that arrives long after the code works. - Get it: Open-licensed families, or the files your brand licence permits you to embed in a server. - Verify: A written note records each family, its licence and where the file came from - [ ] **A disk you are willing to fill** · pennies per month - Why: The cache and the batch output both live on disk and both grow without a ceiling unless you set one. - Get it: A volume with a known size, separate from the system disk. - Verify: The cache ceiling is set below the volume size and eviction has been observed - [ ] **Your allowlist, timeout and cache ceiling** · free - Why: Each of these is asserted by a test, and choosing them after seeing failures means choosing whatever passes. - Get it: Write down which asset hosts are permitted, how long a render may take, and how much disk the cache may use. - Verify: All three appear in the configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A visual template editor. - Video rendering. - Social publishing. - Caller-supplied HTML. - visual studio editor for non developers - 2,000 plus prebuilt templates - one design auto resized to every social format - scheduled and webhook driven workflows - publishing to social platforms - MCP and agent integrations - white label embedded editor - workspaces, audit logs, and team access If one of those is essential to you, that is the reason to keep paying for Orshot, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Orshot The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a template render service that turns a JSON payload into an image or a PDF over HTTP to replace Orshot. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Callers send data. They never send markup, a URL, or a path. A render service is a browser you operate on someone else's behalf, and the moment it will load HTML or an address supplied in a request it will fetch your metadata endpoint, your admin page and the contents of /etc, because that is what a browser does when told to. Templates are files in your repository, reviewed and versioned like code. The request body carries values for a schema the template declares, nothing else. Remote assets come from an allowlist, the file scheme is refused, and loopback and private ranges are refused after the hostname is resolved rather than before, because a name can resolve to 127.0.0.1 on the browser's own lookup. ### Stack (fixed, do not substitute) - Runtime: One Node process with an HTTP API and a pool of reusable headless Chromium pages · the browser is the renderer and starting one per request is the whole performance story - Templates: Server-side folders of HTML, CSS and a declared data schema, versioned in the repository · data is data, markup is code, and the caller supplies only the first - Fonts: Bundled beside the templates, loaded from disk, hashed into the cache key · a system fallback shifts every line and the output still looks plausible - Output: Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template · the two paths use different CSS media and pretending otherwise gives you a colourless invoice - Cache: Content hash of template version, payload and font set, on disk with a size ceiling · the same card is asked for a thousand times and should be drawn once - Isolation: Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution · an open renderer fetches whatever the caller names, including your metadata endpoint ### Data model (create this before Phase 1) - templates(name, version, width, height, default_format, schema_json, font_set, updated_at) · version is part of every cache key - renders(id, template, template_version, payload_hash, format, scale, bytes, duration_ms, cache_hit, api_key_id, created_at) - assets(pattern, kind, sha256, allowed) · every remote image a template may load, checked before a page is opened - fonts(family, weight, style, file, sha256) · hashed into the cache key so replacing a font invalidates only what used it - api_keys(id, label, hashed_key, rate_per_minute, monthly_render_cap, created_at, revoked_at) - failures(id, template, payload_hash, stage, message, console_excerpt, at) · the page's console output, because most failures are a missing asset nobody saw - The cache key is the hash of template version, payload and font set together, so an identical request never opens a browser and editing one template invalidates nothing else. ### Phase 1 · One template that renders the same everywhere Build: A template folder with index.html, style.css and meta.json declaring width, height, scale and a data schema. Bundle the fonts beside it and load them from disk. Set the viewport from meta.json, wait until the document reports its fonts ready and its images decoded, then screenshot. Add a golden-image test that fails on a pixel difference above a stated threshold. Trap: Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. Done when: A payload rendered on the host and inside the container produces images within the stated pixel threshold, tested in CI. The computed font family of every text node matches a bundled face, asserted rather than eyeballed. Removing a bundled font file fails the render loudly instead of falling back. The declared scale produces the expected pixel dimensions for both scale one and scale two. Do not build yet: No API keys, caching, PDF, batching or a browser pool. ### Phase 2 · Data in, never markup Build: Validate the payload against the template's declared schema and reject unknown keys. Escape every interpolated value so it can only become text or an attribute value. Resolve remote asset addresses against an allowlist, refuse the file scheme, and after resolving the hostname refuse loopback and private ranges. Run the browser with no access to the host filesystem beyond the template directory. Trap: Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. Done when: A payload containing a script tag renders as visible text and executes nothing, tested. An unknown key in the payload is rejected with the offending key named. A template asked for an image on an address outside the allowlist fails the render with a clear reason. A hostname that resolves into a private range is refused, tested with a controlled DNS entry. Do not build yet: No PDF, browser pool, caching or keys yet. ### Phase 3 · PDFs that look like the design Build: Generate PDFs through the browser's print path, with the page size taken from meta.json in real units. Playwright's own documentation says page.pdf generates a PDF with print CSS media and that by default it produces modified colours for printing, recommending print-color-adjust to force exact colours (checked). So decide per template whether to emulate screen media or write print styles, and set the colour adjustment explicitly. Handle page breaks and a multi-page document. Trap: Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. Done when: A template with a coloured background produces a PDF containing that colour, asserted by sampling the rasterised page. The page size in the PDF matches meta.json in millimetres, not in pixels. A two-page document breaks where the template says and not mid-row. The golden test covers the PDF path as well as the image path. Do not build yet: No pool, cache, keys or batching yet. ### Phase 4 · A browser pool that survives Monday Build: Keep a small pool of browser contexts warm, one fresh context per render so state cannot leak between callers. Cap concurrency, queue beyond it with a bounded depth, and answer with a retry-after once the queue is full rather than accepting work you will not do. Give every render a hard timeout that kills the page, and restart a browser after a stated number of renders or when memory crosses a ceiling. Trap: A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. Done when: A render that hangs is killed at the stated timeout and the worker is back in the pool, tested with a template that waits on a dead address. Two hundred concurrent requests produce completed renders and explicit queue-full responses, never a crash, tested. Memory after a thousand renders is within a stated band, measured. One caller's payload never appears in another caller's output, because each render gets a fresh context. Do not build yet: No cache, keys, budgets or logs yet. ### Phase 5 · Caching, keys and a bill that cannot run away Build: Cache by the hash of template version, payload and font set, with an on-disk ceiling and least-recently-used eviction. Serve a hit without opening a browser and say so in a header. Issue API keys with a per-minute rate and a monthly render cap, refusing past the cap rather than billing through it. Log every render with its template, duration, cache status and key. Add a batch endpoint that streams a manifest and honours the same caps. Trap: Caching on the payload alone means editing a template or swapping a font silently serves last week's picture, and the person who made the edit assumes it did not deploy. Put the template version and a hash of the font set in the key, and invalidate by writing a new key rather than by clearing the cache. Done when: A repeated request is served from disk without opening a browser, verified by the render count and the response header. Editing a template changes its version and the next request renders fresh, tested. A key past its monthly cap is refused with a clear reason and the refusal is logged. The cache stays under its disk ceiling under a generated workload, measured rather than assumed. Do not build yet: Nothing after this. ### Out of scope (and why) - A visual editor for non-developers, which is the actual product being replaced here and is a design tool with a rendering pipeline attached, not a weekend. - Video rendering, which needs a frame pipeline, an encoder and a job model that has nothing in common with taking a screenshot. - Publishing to social platforms, since each one is an application review, a token lifecycle and a set of rules you must read. - Accepting HTML from callers, which is the one feature that would turn this into an open browser on the internet. ### README must contain - That templates live in the repository and callers send only data validated against a declared schema - How remote assets are allowlisted, and that the resolved address is checked rather than the hostname - That fonts are bundled and hashed into the cache key, so replacing one invalidates exactly what used it - That PDFs go through the print path, which applies print CSS media and adjusts colours unless told otherwise - How to add a template, with the golden-image test that must accompany it, and the container line that installs the Chromium system libraries ===== AGENTS.md ===== # Agent instructions · Orshot indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One Node process with an HTTP API and a pool of reusable headless Chromium pages, Server-side folders of HTML, CSS and a declared data schema, versioned in the repository, Bundled beside the templates, loaded from disk, hashed into the cache key, Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template, Content hash of template version, payload and font set, on disk with a size ceiling, Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. ===== BUILD_PLAN.md ===== # Build plan · Orshot A template-driven render service whose hard problems are producing the same pixels on every machine, keeping a browser pool warm and bounded under burst, and never letting a caller's data turn into code the browser runs. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One template that renders the same everywhere The same payload produces the same pixels on your laptop and in a container. ### Steps 1. Declare size, scale and schema in meta.json The template states what it needs so the service can reject a payload before opening a browser. Files: `templates/og-card/meta.json`, `src/templates.ts` 2. Wait for fonts and images, not for load Load fires long before the page looks like the design. Files: `src/render.ts` 3. Add a golden-image test with a threshold A rendering regression is invisible in a diff and obvious in a picture. Files: `tests/golden.test.ts`, `tests/golden/og-card.png` ### Done when - [ ] Host and container renders match within the threshold - [ ] Every text node resolves to a bundled font - [ ] A missing font file fails loudly - [ ] Declared scale produces the expected dimensions ### Watch out - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. ## Phase 2 · Data in, never markup A hostile payload cannot make the browser fetch or run anything you did not choose. ### Steps 1. Validate against the declared schema Unknown keys are rejected, not ignored, so a typo is an error rather than a blank. Files: `src/validate.ts`, `tests/schema.test.ts` 2. Escape every interpolation by default Raw insertion should not be reachable from a request at all. Files: `src/render.ts` 3. Check the resolved address, not the hostname This is the difference between an allowlist and the appearance of one. Files: `src/assets.ts`, `tests/ssrf.test.ts` ### Done when - [ ] A script tag in the payload renders as text - [ ] Unknown payload keys are rejected by name - [ ] Off-allowlist assets fail the render - [ ] A hostname resolving to a private range is refused ### Watch out - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. ## Phase 3 · PDFs that look like the design The invoice on screen and the invoice in the file are the same document. ### Steps 1. Choose the CSS media per template and record it Print and screen are different documents and the template should say which it is. Files: `templates/invoice/meta.json`, `src/pdf.ts` 2. Force the colour adjustment explicitly The default is tuned for ink, and your design is not. Files: `templates/invoice/style.css` 3. Rasterise the PDF in the golden test Comparing bytes tells you nothing; comparing pixels tells you everything. Files: `tests/golden-pdf.test.ts` ### Done when - [ ] Background colours survive into the PDF - [ ] Page size matches meta.json in real units - [ ] Page breaks land where the template says - [ ] The golden test covers the PDF path ### Watch out - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. ## Phase 4 · A browser pool that survives Monday Burst traffic queues instead of exhausting memory, and a hung page never holds a worker. ### Steps 1. Warm pool, fresh context per render Reuse the expensive part, discard the part that holds state. Files: `src/pool.ts` 2. Bound the queue and say no Accepting work you cannot finish turns a slow minute into an outage. Files: `src/queue.ts`, `tests/load.test.ts` 3. Timeout, kill, recycle Measure memory over a thousand renders and set the recycle point from the measurement. Files: `src/pool.ts`, `tests/timeout.test.ts` ### Done when - [ ] A hung render is killed and the worker returns - [ ] Overload produces queue-full responses, not a crash - [ ] Memory after a thousand renders stays in band - [ ] No state leaks between renders ### Watch out - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. ## Not in this build - A visual template editor. - Video rendering. - Social publishing. - Caller-supplied HTML. ## After v1, if you want it - Signed render URLs so a template and payload can be embedded in a page without exposing a key - A second output size derived from the same template, once the golden tests cover the first - A preview endpoint that renders at low scale for editing, sharing the cache with the full-size path ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The HTTP API. Bind to localhost unless a proxy terminates TLS in front. PORT=8080 # Required. Server-side templates. The only markup the browser ever renders. TEMPLATES_DIR=./templates # Required. Bundled faces. Hashed into the cache key. FONTS_DIR=./fonts # Required. Rendered output by content hash, evicted least recently used. CACHE_DIR=./.cache # Required. The disk ceiling. Eviction is tested against it, not hoped for. CACHE_MAX_BYTES=5368709120 # Required. After this the page is killed, not navigated away from. RENDER_TIMEOUT_MS=15000 # Required. Warm contexts. Beyond this requests queue and then are refused. POOL_SIZE=4 # Required. Hostnames a template may load from. Resolved addresses are checked separately. ASSET_ALLOWLIST=cdn.example.com,images.example.org
You are building a lean indie version of Orshot. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Orshot · indie build A template-driven render service whose hard problems are producing the same pixels on every machine, keeping a browser pool warm and bounded under burst, and never letting a caller's data turn into code the browser runs. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One Node process with an HTTP API and a pool of reusable headless Chromium pages | the browser is the renderer and starting one per request is the whole performance story | | Templates | Server-side folders of HTML, CSS and a declared data schema, versioned in the repository | data is data, markup is code, and the caller supplies only the first | | Fonts | Bundled beside the templates, loaded from disk, hashed into the cache key | a system fallback shifts every line and the output still looks plausible | | Output | Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template | the two paths use different CSS media and pretending otherwise gives you a colourless invoice | | Cache | Content hash of template version, payload and font set, on disk with a size ceiling | the same card is asked for a thousand times and should be drawn once | | Isolation | Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution | an open renderer fetches whatever the caller names, including your metadata endpoint | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the service and the browser driver. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Playwright with Chromium and its system libraries** · free - Why: The renderer. Installing the browser without its system libraries is the most common cause of a service that starts cleanly and renders nothing. - Get it: npm i -D playwright then npx playwright install --with-deps chromium. - Verify: A scripted screenshot of a local page produces a non-empty PNG inside the container - [ ] **Licensed font files you may embed** · free with open licences - Why: Bundling a font you have no licence to redistribute is a legal problem that arrives long after the code works. - Get it: Open-licensed families, or the files your brand licence permits you to embed in a server. - Verify: A written note records each family, its licence and where the file came from - [ ] **A disk you are willing to fill** · pennies per month - Why: The cache and the batch output both live on disk and both grow without a ceiling unless you set one. - Get it: A volume with a known size, separate from the system disk. - Verify: The cache ceiling is set below the volume size and eviction has been observed - [ ] **Your allowlist, timeout and cache ceiling** · free - Why: Each of these is asserted by a test, and choosing them after seeing failures means choosing whatever passes. - Get it: Write down which asset hosts are permitted, how long a render may take, and how much disk the cache may use. - Verify: All three appear in the configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A visual template editor. - Video rendering. - Social publishing. - Caller-supplied HTML. - visual studio editor for non developers - 2,000 plus prebuilt templates - one design auto resized to every social format - scheduled and webhook driven workflows - publishing to social platforms - MCP and agent integrations - white label embedded editor - workspaces, audit logs, and team access If one of those is essential to you, that is the reason to keep paying for Orshot, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Orshot The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a template render service that turns a JSON payload into an image or a PDF over HTTP to replace Orshot. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Callers send data. They never send markup, a URL, or a path. A render service is a browser you operate on someone else's behalf, and the moment it will load HTML or an address supplied in a request it will fetch your metadata endpoint, your admin page and the contents of /etc, because that is what a browser does when told to. Templates are files in your repository, reviewed and versioned like code. The request body carries values for a schema the template declares, nothing else. Remote assets come from an allowlist, the file scheme is refused, and loopback and private ranges are refused after the hostname is resolved rather than before, because a name can resolve to 127.0.0.1 on the browser's own lookup. ### Stack (fixed, do not substitute) - Runtime: One Node process with an HTTP API and a pool of reusable headless Chromium pages · the browser is the renderer and starting one per request is the whole performance story - Templates: Server-side folders of HTML, CSS and a declared data schema, versioned in the repository · data is data, markup is code, and the caller supplies only the first - Fonts: Bundled beside the templates, loaded from disk, hashed into the cache key · a system fallback shifts every line and the output still looks plausible - Output: Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template · the two paths use different CSS media and pretending otherwise gives you a colourless invoice - Cache: Content hash of template version, payload and font set, on disk with a size ceiling · the same card is asked for a thousand times and should be drawn once - Isolation: Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution · an open renderer fetches whatever the caller names, including your metadata endpoint ### Data model (create this before Phase 1) - templates(name, version, width, height, default_format, schema_json, font_set, updated_at) · version is part of every cache key - renders(id, template, template_version, payload_hash, format, scale, bytes, duration_ms, cache_hit, api_key_id, created_at) - assets(pattern, kind, sha256, allowed) · every remote image a template may load, checked before a page is opened - fonts(family, weight, style, file, sha256) · hashed into the cache key so replacing a font invalidates only what used it - api_keys(id, label, hashed_key, rate_per_minute, monthly_render_cap, created_at, revoked_at) - failures(id, template, payload_hash, stage, message, console_excerpt, at) · the page's console output, because most failures are a missing asset nobody saw - The cache key is the hash of template version, payload and font set together, so an identical request never opens a browser and editing one template invalidates nothing else. ### Phase 1 · One template that renders the same everywhere Build: A template folder with index.html, style.css and meta.json declaring width, height, scale and a data schema. Bundle the fonts beside it and load them from disk. Set the viewport from meta.json, wait until the document reports its fonts ready and its images decoded, then screenshot. Add a golden-image test that fails on a pixel difference above a stated threshold. Trap: Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. Done when: A payload rendered on the host and inside the container produces images within the stated pixel threshold, tested in CI. The computed font family of every text node matches a bundled face, asserted rather than eyeballed. Removing a bundled font file fails the render loudly instead of falling back. The declared scale produces the expected pixel dimensions for both scale one and scale two. Do not build yet: No API keys, caching, PDF, batching or a browser pool. ### Phase 2 · Data in, never markup Build: Validate the payload against the template's declared schema and reject unknown keys. Escape every interpolated value so it can only become text or an attribute value. Resolve remote asset addresses against an allowlist, refuse the file scheme, and after resolving the hostname refuse loopback and private ranges. Run the browser with no access to the host filesystem beyond the template directory. Trap: Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. Done when: A payload containing a script tag renders as visible text and executes nothing, tested. An unknown key in the payload is rejected with the offending key named. A template asked for an image on an address outside the allowlist fails the render with a clear reason. A hostname that resolves into a private range is refused, tested with a controlled DNS entry. Do not build yet: No PDF, browser pool, caching or keys yet. ### Phase 3 · PDFs that look like the design Build: Generate PDFs through the browser's print path, with the page size taken from meta.json in real units. Playwright's own documentation says page.pdf generates a PDF with print CSS media and that by default it produces modified colours for printing, recommending print-color-adjust to force exact colours (checked). So decide per template whether to emulate screen media or write print styles, and set the colour adjustment explicitly. Handle page breaks and a multi-page document. Trap: Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. Done when: A template with a coloured background produces a PDF containing that colour, asserted by sampling the rasterised page. The page size in the PDF matches meta.json in millimetres, not in pixels. A two-page document breaks where the template says and not mid-row. The golden test covers the PDF path as well as the image path. Do not build yet: No pool, cache, keys or batching yet. ### Phase 4 · A browser pool that survives Monday Build: Keep a small pool of browser contexts warm, one fresh context per render so state cannot leak between callers. Cap concurrency, queue beyond it with a bounded depth, and answer with a retry-after once the queue is full rather than accepting work you will not do. Give every render a hard timeout that kills the page, and restart a browser after a stated number of renders or when memory crosses a ceiling. Trap: A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. Done when: A render that hangs is killed at the stated timeout and the worker is back in the pool, tested with a template that waits on a dead address. Two hundred concurrent requests produce completed renders and explicit queue-full responses, never a crash, tested. Memory after a thousand renders is within a stated band, measured. One caller's payload never appears in another caller's output, because each render gets a fresh context. Do not build yet: No cache, keys, budgets or logs yet. ### Phase 5 · Caching, keys and a bill that cannot run away Build: Cache by the hash of template version, payload and font set, with an on-disk ceiling and least-recently-used eviction. Serve a hit without opening a browser and say so in a header. Issue API keys with a per-minute rate and a monthly render cap, refusing past the cap rather than billing through it. Log every render with its template, duration, cache status and key. Add a batch endpoint that streams a manifest and honours the same caps. Trap: Caching on the payload alone means editing a template or swapping a font silently serves last week's picture, and the person who made the edit assumes it did not deploy. Put the template version and a hash of the font set in the key, and invalidate by writing a new key rather than by clearing the cache. Done when: A repeated request is served from disk without opening a browser, verified by the render count and the response header. Editing a template changes its version and the next request renders fresh, tested. A key past its monthly cap is refused with a clear reason and the refusal is logged. The cache stays under its disk ceiling under a generated workload, measured rather than assumed. Do not build yet: Nothing after this. ### Out of scope (and why) - A visual editor for non-developers, which is the actual product being replaced here and is a design tool with a rendering pipeline attached, not a weekend. - Video rendering, which needs a frame pipeline, an encoder and a job model that has nothing in common with taking a screenshot. - Publishing to social platforms, since each one is an application review, a token lifecycle and a set of rules you must read. - Accepting HTML from callers, which is the one feature that would turn this into an open browser on the internet. ### README must contain - That templates live in the repository and callers send only data validated against a declared schema - How remote assets are allowlisted, and that the resolved address is checked rather than the hostname - That fonts are bundled and hashed into the cache key, so replacing one invalidates exactly what used it - That PDFs go through the print path, which applies print CSS media and adjusts colours unless told otherwise - How to add a template, with the golden-image test that must accompany it, and the container line that installs the Chromium system libraries ===== AGENTS.md ===== # Agent instructions · Orshot indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One Node process with an HTTP API and a pool of reusable headless Chromium pages, Server-side folders of HTML, CSS and a declared data schema, versioned in the repository, Bundled beside the templates, loaded from disk, hashed into the cache key, Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template, Content hash of template version, payload and font set, on disk with a size ceiling, Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. ===== BUILD_PLAN.md ===== # Build plan · Orshot A template-driven render service whose hard problems are producing the same pixels on every machine, keeping a browser pool warm and bounded under burst, and never letting a caller's data turn into code the browser runs. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One template that renders the same everywhere The same payload produces the same pixels on your laptop and in a container. ### Steps 1. Declare size, scale and schema in meta.json The template states what it needs so the service can reject a payload before opening a browser. Files: `templates/og-card/meta.json`, `src/templates.ts` 2. Wait for fonts and images, not for load Load fires long before the page looks like the design. Files: `src/render.ts` 3. Add a golden-image test with a threshold A rendering regression is invisible in a diff and obvious in a picture. Files: `tests/golden.test.ts`, `tests/golden/og-card.png` ### Done when - [ ] Host and container renders match within the threshold - [ ] Every text node resolves to a bundled font - [ ] A missing font file fails loudly - [ ] Declared scale produces the expected dimensions ### Watch out - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. ## Phase 2 · Data in, never markup A hostile payload cannot make the browser fetch or run anything you did not choose. ### Steps 1. Validate against the declared schema Unknown keys are rejected, not ignored, so a typo is an error rather than a blank. Files: `src/validate.ts`, `tests/schema.test.ts` 2. Escape every interpolation by default Raw insertion should not be reachable from a request at all. Files: `src/render.ts` 3. Check the resolved address, not the hostname This is the difference between an allowlist and the appearance of one. Files: `src/assets.ts`, `tests/ssrf.test.ts` ### Done when - [ ] A script tag in the payload renders as text - [ ] Unknown payload keys are rejected by name - [ ] Off-allowlist assets fail the render - [ ] A hostname resolving to a private range is refused ### Watch out - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. ## Phase 3 · PDFs that look like the design The invoice on screen and the invoice in the file are the same document. ### Steps 1. Choose the CSS media per template and record it Print and screen are different documents and the template should say which it is. Files: `templates/invoice/meta.json`, `src/pdf.ts` 2. Force the colour adjustment explicitly The default is tuned for ink, and your design is not. Files: `templates/invoice/style.css` 3. Rasterise the PDF in the golden test Comparing bytes tells you nothing; comparing pixels tells you everything. Files: `tests/golden-pdf.test.ts` ### Done when - [ ] Background colours survive into the PDF - [ ] Page size matches meta.json in real units - [ ] Page breaks land where the template says - [ ] The golden test covers the PDF path ### Watch out - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. ## Phase 4 · A browser pool that survives Monday Burst traffic queues instead of exhausting memory, and a hung page never holds a worker. ### Steps 1. Warm pool, fresh context per render Reuse the expensive part, discard the part that holds state. Files: `src/pool.ts` 2. Bound the queue and say no Accepting work you cannot finish turns a slow minute into an outage. Files: `src/queue.ts`, `tests/load.test.ts` 3. Timeout, kill, recycle Measure memory over a thousand renders and set the recycle point from the measurement. Files: `src/pool.ts`, `tests/timeout.test.ts` ### Done when - [ ] A hung render is killed and the worker returns - [ ] Overload produces queue-full responses, not a crash - [ ] Memory after a thousand renders stays in band - [ ] No state leaks between renders ### Watch out - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. ## Not in this build - A visual template editor. - Video rendering. - Social publishing. - Caller-supplied HTML. ## After v1, if you want it - Signed render URLs so a template and payload can be embedded in a page without exposing a key - A second output size derived from the same template, once the golden tests cover the first - A preview endpoint that renders at low scale for editing, sharing the cache with the full-size path ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The HTTP API. Bind to localhost unless a proxy terminates TLS in front. PORT=8080 # Required. Server-side templates. The only markup the browser ever renders. TEMPLATES_DIR=./templates # Required. Bundled faces. Hashed into the cache key. FONTS_DIR=./fonts # Required. Rendered output by content hash, evicted least recently used. CACHE_DIR=./.cache # Required. The disk ceiling. Eviction is tested against it, not hoped for. CACHE_MAX_BYTES=5368709120 # Required. After this the page is killed, not navigated away from. RENDER_TIMEOUT_MS=15000 # Required. Warm contexts. Beyond this requests queue and then are refused. POOL_SIZE=4 # Required. Hostnames a template may load from. Resolved addresses are checked separately. ASSET_ALLOWLIST=cdn.example.com,images.example.org
You are building a production product version of Orshot. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Orshot · product brief ## Problem The render itself is not the moat. Headless Chromium plus a Handlebars template gets you a PNG or a PDF from JSON in an afternoon, and for one team rendering its own OG images that really is the whole job. What does not fall out of a weekend is everything around the render: a visual editor non engineers can use, one design that re solves cleanly into a story, a square, and an OG card, workflows that fire on a schedule or a webhook against Sheets and Airtable, publishing into 15 plus social platforms, and a render farm that stays warm and predictable under burst. Build it if you own the templates and the volume is small. Keep paying if the people making the creatives do not write HTML. ## Product outcome A render service that produces identical output on any machine, refuses anything a caller tries to make it fetch or run, keeps its browsers warm and bounded, and cannot be made expensive by a client stuck in a retry loop. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - headless Chromium (Playwright or Puppeteer) - a template language and a place to store templates - a warm browser pool and a render queue - font and brand asset handling - object storage plus a CDN for delivery - a host that tolerates burst traffic ## Explicit non-goals for v1 - A visual template editor. - Video rendering. - Social publishing. - Caller-supplied HTML. - visual studio editor for non developers - 2,000 plus prebuilt templates - one design auto resized to every social format - scheduled and webhook driven workflows - publishing to social platforms - MCP and agent integrations - white label embedded editor - workspaces, audit logs, and team access ## Success criteria - Host and container renders are confirmed to match within the threshold - A script tag in the payload is confirmed to render as text - A hostname resolving to a private range is confirmed refused - PDF colour and page size are confirmed against the design - A hung render is confirmed killed and the worker returned ===== BRIEF.md ===== # Build brief · Orshot The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a template render service that turns a JSON payload into an image or a PDF over HTTP to replace Orshot. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Callers send data. They never send markup, a URL, or a path. A render service is a browser you operate on someone else's behalf, and the moment it will load HTML or an address supplied in a request it will fetch your metadata endpoint, your admin page and the contents of /etc, because that is what a browser does when told to. Templates are files in your repository, reviewed and versioned like code. The request body carries values for a schema the template declares, nothing else. Remote assets come from an allowlist, the file scheme is refused, and loopback and private ranges are refused after the hostname is resolved rather than before, because a name can resolve to 127.0.0.1 on the browser's own lookup. ### Stack (fixed, do not substitute) - Runtime: One Node process with an HTTP API and a pool of reusable headless Chromium pages · the browser is the renderer and starting one per request is the whole performance story - Templates: Server-side folders of HTML, CSS and a declared data schema, versioned in the repository · data is data, markup is code, and the caller supplies only the first - Fonts: Bundled beside the templates, loaded from disk, hashed into the cache key · a system fallback shifts every line and the output still looks plausible - Output: Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template · the two paths use different CSS media and pretending otherwise gives you a colourless invoice - Cache: Content hash of template version, payload and font set, on disk with a size ceiling · the same card is asked for a thousand times and should be drawn once - Isolation: Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution · an open renderer fetches whatever the caller names, including your metadata endpoint ### Data model (create this before Phase 1) - templates(name, version, width, height, default_format, schema_json, font_set, updated_at) · version is part of every cache key - renders(id, template, template_version, payload_hash, format, scale, bytes, duration_ms, cache_hit, api_key_id, created_at) - assets(pattern, kind, sha256, allowed) · every remote image a template may load, checked before a page is opened - fonts(family, weight, style, file, sha256) · hashed into the cache key so replacing a font invalidates only what used it - api_keys(id, label, hashed_key, rate_per_minute, monthly_render_cap, created_at, revoked_at) - failures(id, template, payload_hash, stage, message, console_excerpt, at) · the page's console output, because most failures are a missing asset nobody saw - The cache key is the hash of template version, payload and font set together, so an identical request never opens a browser and editing one template invalidates nothing else. ### Phase 1 · One template that renders the same everywhere Build: A template folder with index.html, style.css and meta.json declaring width, height, scale and a data schema. Bundle the fonts beside it and load them from disk. Set the viewport from meta.json, wait until the document reports its fonts ready and its images decoded, then screenshot. Add a golden-image test that fails on a pixel difference above a stated threshold. Trap: Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. Done when: A payload rendered on the host and inside the container produces images within the stated pixel threshold, tested in CI. The computed font family of every text node matches a bundled face, asserted rather than eyeballed. Removing a bundled font file fails the render loudly instead of falling back. The declared scale produces the expected pixel dimensions for both scale one and scale two. Do not build yet: No API keys, caching, PDF, batching or a browser pool. ### Phase 2 · Data in, never markup Build: Validate the payload against the template's declared schema and reject unknown keys. Escape every interpolated value so it can only become text or an attribute value. Resolve remote asset addresses against an allowlist, refuse the file scheme, and after resolving the hostname refuse loopback and private ranges. Run the browser with no access to the host filesystem beyond the template directory. Trap: Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. Done when: A payload containing a script tag renders as visible text and executes nothing, tested. An unknown key in the payload is rejected with the offending key named. A template asked for an image on an address outside the allowlist fails the render with a clear reason. A hostname that resolves into a private range is refused, tested with a controlled DNS entry. Do not build yet: No PDF, browser pool, caching or keys yet. ### Phase 3 · PDFs that look like the design Build: Generate PDFs through the browser's print path, with the page size taken from meta.json in real units. Playwright's own documentation says page.pdf generates a PDF with print CSS media and that by default it produces modified colours for printing, recommending print-color-adjust to force exact colours (checked). So decide per template whether to emulate screen media or write print styles, and set the colour adjustment explicitly. Handle page breaks and a multi-page document. Trap: Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. Done when: A template with a coloured background produces a PDF containing that colour, asserted by sampling the rasterised page. The page size in the PDF matches meta.json in millimetres, not in pixels. A two-page document breaks where the template says and not mid-row. The golden test covers the PDF path as well as the image path. Do not build yet: No pool, cache, keys or batching yet. ### Phase 4 · A browser pool that survives Monday Build: Keep a small pool of browser contexts warm, one fresh context per render so state cannot leak between callers. Cap concurrency, queue beyond it with a bounded depth, and answer with a retry-after once the queue is full rather than accepting work you will not do. Give every render a hard timeout that kills the page, and restart a browser after a stated number of renders or when memory crosses a ceiling. Trap: A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. Done when: A render that hangs is killed at the stated timeout and the worker is back in the pool, tested with a template that waits on a dead address. Two hundred concurrent requests produce completed renders and explicit queue-full responses, never a crash, tested. Memory after a thousand renders is within a stated band, measured. One caller's payload never appears in another caller's output, because each render gets a fresh context. Do not build yet: No cache, keys, budgets or logs yet. ### Phase 5 · Caching, keys and a bill that cannot run away Build: Cache by the hash of template version, payload and font set, with an on-disk ceiling and least-recently-used eviction. Serve a hit without opening a browser and say so in a header. Issue API keys with a per-minute rate and a monthly render cap, refusing past the cap rather than billing through it. Log every render with its template, duration, cache status and key. Add a batch endpoint that streams a manifest and honours the same caps. Trap: Caching on the payload alone means editing a template or swapping a font silently serves last week's picture, and the person who made the edit assumes it did not deploy. Put the template version and a hash of the font set in the key, and invalidate by writing a new key rather than by clearing the cache. Done when: A repeated request is served from disk without opening a browser, verified by the render count and the response header. Editing a template changes its version and the next request renders fresh, tested. A key past its monthly cap is refused with a clear reason and the refusal is logged. The cache stays under its disk ceiling under a generated workload, measured rather than assumed. Do not build yet: Nothing after this. ### Out of scope (and why) - A visual editor for non-developers, which is the actual product being replaced here and is a design tool with a rendering pipeline attached, not a weekend. - Video rendering, which needs a frame pipeline, an encoder and a job model that has nothing in common with taking a screenshot. - Publishing to social platforms, since each one is an application review, a token lifecycle and a set of rules you must read. - Accepting HTML from callers, which is the one feature that would turn this into an open browser on the internet. ### README must contain - That templates live in the repository and callers send only data validated against a declared schema - How remote assets are allowlisted, and that the resolved address is checked rather than the hostname - That fonts are bundled and hashed into the cache key, so replacing one invalidates exactly what used it - That PDFs go through the print path, which applies print CSS media and adjusts colours unless told otherwise - How to add a template, with the golden-image test that must accompany it, and the container line that installs the Chromium system libraries ===== ARCHITECTURE.md ===== # Architecture · Orshot ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One Node process with an HTTP API and a pool of reusable headless Chromium pages | the browser is the renderer and starting one per request is the whole performance story | | Templates | Server-side folders of HTML, CSS and a declared data schema, versioned in the repository | data is data, markup is code, and the caller supplies only the first | | Fonts | Bundled beside the templates, loaded from disk, hashed into the cache key | a system fallback shifts every line and the output still looks plausible | | Output | Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template | the two paths use different CSS media and pretending otherwise gives you a colourless invoice | | Cache | Content hash of template version, payload and font set, on disk with a size ceiling | the same card is asked for a thousand times and should be drawn once | | Isolation | Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution | an open renderer fetches whatever the caller names, including your metadata endpoint | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Templates | Markup, styles and a declared schema, versioned. | Any template language, provided callers still send only data. | | Assets | Allowlisting and address checks before the browser opens. | A fetching proxy instead of in-process checks, provided the resolved address is what is checked. | | Renderer | Screenshot and print paths, font readiness, scale. | Another browser driver, provided the golden tests still pass. | | Pool | Warm contexts, timeouts, recycling, the bounded queue. | An external worker pool, provided overload still refuses rather than accepts. | | Cache | Content hashing, eviction, the disk ceiling. | Object storage, provided the key still includes template version and fonts. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · The HTTP API. Bind to localhost unless a proxy terminates TLS in front. - `TEMPLATES_DIR` · required · Server-side templates. The only markup the browser ever renders. - `FONTS_DIR` · required · Bundled faces. Hashed into the cache key. - `CACHE_DIR` · required · Rendered output by content hash, evicted least recently used. - `CACHE_MAX_BYTES` · required · The disk ceiling. Eviction is tested against it, not hoped for. - `RENDER_TIMEOUT_MS` · required · After this the page is killed, not navigated away from. - `POOL_SIZE` · required · Warm contexts. Beyond this requests queue and then are refused. - `ASSET_ALLOWLIST` · required · Hostnames a template may load from. Resolved addresses are checked separately. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Orshot product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One Node process with an HTTP API and a pool of reusable headless Chromium pages, Server-side folders of HTML, CSS and a declared data schema, versioned in the repository, Bundled beside the templates, loaded from disk, hashed into the cache key, Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template, Content hash of template version, payload and font set, on disk with a size ceiling, Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. - Caching on the payload alone means editing a template or swapping a font silently serves last week's picture, and the person who made the edit assumes it did not deploy. Put the template version and a hash of the font set in the key, and invalidate by writing a new key rather than by clearing the cache. ===== MILESTONES.md ===== # Delivery milestones · Orshot Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One template that renders the same everywhere The same payload produces the same pixels on your laptop and in a container. ### Steps 1. Declare size, scale and schema in meta.json The template states what it needs so the service can reject a payload before opening a browser. Files: `templates/og-card/meta.json`, `src/templates.ts` 2. Wait for fonts and images, not for load Load fires long before the page looks like the design. Files: `src/render.ts` 3. Add a golden-image test with a threshold A rendering regression is invisible in a diff and obvious in a picture. Files: `tests/golden.test.ts`, `tests/golden/og-card.png` ### Done when - [ ] Host and container renders match within the threshold - [ ] Every text node resolves to a bundled font - [ ] A missing font file fails loudly - [ ] Declared scale produces the expected dimensions ### Watch out - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. ## M2 · Data in, never markup A hostile payload cannot make the browser fetch or run anything you did not choose. ### Steps 1. Validate against the declared schema Unknown keys are rejected, not ignored, so a typo is an error rather than a blank. Files: `src/validate.ts`, `tests/schema.test.ts` 2. Escape every interpolation by default Raw insertion should not be reachable from a request at all. Files: `src/render.ts` 3. Check the resolved address, not the hostname This is the difference between an allowlist and the appearance of one. Files: `src/assets.ts`, `tests/ssrf.test.ts` ### Done when - [ ] A script tag in the payload renders as text - [ ] Unknown payload keys are rejected by name - [ ] Off-allowlist assets fail the render - [ ] A hostname resolving to a private range is refused ### Watch out - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. ## M3 · PDFs that look like the design The invoice on screen and the invoice in the file are the same document. ### Steps 1. Choose the CSS media per template and record it Print and screen are different documents and the template should say which it is. Files: `templates/invoice/meta.json`, `src/pdf.ts` 2. Force the colour adjustment explicitly The default is tuned for ink, and your design is not. Files: `templates/invoice/style.css` 3. Rasterise the PDF in the golden test Comparing bytes tells you nothing; comparing pixels tells you everything. Files: `tests/golden-pdf.test.ts` ### Done when - [ ] Background colours survive into the PDF - [ ] Page size matches meta.json in real units - [ ] Page breaks land where the template says - [ ] The golden test covers the PDF path ### Watch out - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. ## M4 · A browser pool that survives Monday Burst traffic queues instead of exhausting memory, and a hung page never holds a worker. ### Steps 1. Warm pool, fresh context per render Reuse the expensive part, discard the part that holds state. Files: `src/pool.ts` 2. Bound the queue and say no Accepting work you cannot finish turns a slow minute into an outage. Files: `src/queue.ts`, `tests/load.test.ts` 3. Timeout, kill, recycle Measure memory over a thousand renders and set the recycle point from the measurement. Files: `src/pool.ts`, `tests/timeout.test.ts` ### Done when - [ ] A hung render is killed and the worker returns - [ ] Overload produces queue-full responses, not a crash - [ ] Memory after a thousand renders stays in band - [ ] No state leaks between renders ### Watch out - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. ## M5 · Caching, keys and a bill that cannot run away (production only) Identical work is done once, every caller is identified, and a loop cannot cost you a month of compute. ### Steps 1. Hash template version, payload and fonts together One key covering everything that changes the picture. Files: `src/cache.ts`, `tests/cache-key.test.ts` 2. Cap per key, per month, and refuse A caller in a retry loop is the most likely source of a large bill. Files: `src/keys.ts` 3. Log template, duration and cache status per render Without it you cannot tell a slow template from a cold cache. Files: `src/log.ts` ### Done when - [ ] Repeat requests never open a browser - [ ] A template edit invalidates its own cache only - [ ] A key past its cap is refused and logged - [ ] Cache disk stays under the ceiling ### Watch out - Caching on the payload alone means editing a template or swapping a font silently serves last week's picture, and the person who made the edit assumes it did not deploy. Put the template version and a hash of the font set in the key, and invalidate by writing a new key rather than by clearing the cache. ===== OPERATIONS.md ===== # Operations · Orshot ## Backup Templates and fonts are in the repository, which is the backup. The cache is disposable by design and should never hold the only copy of anything, so a batch job writes its output somewhere durable rather than into the cache directory. ## Restore Restoring is a redeploy plus a cold cache, and the only thing to verify is that the golden tests pass on the restored host, since a missing system library for the browser produces blank images rather than errors. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Render duration by template, cache hit rate, queue depth, timeout count and browser recycles. A rising timeout count is the early signal that a template is waiting on an asset that has gone away. ## Incident checklist A blank or wrong-looking image is nearly always a missing font, a missing system library or an asset that failed to load, and the failures table keeps the page console excerpt precisely so that question takes a minute rather than an afternoon. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Host and container renders are confirmed to match within the threshold - [ ] A script tag in the payload is confirmed to render as text - [ ] A hostname resolving to a private range is confirmed refused - [ ] PDF colour and page size are confirmed against the design - [ ] A hung render is confirmed killed and the worker returned ## Launch constraint Do not market omitted Orshot capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The HTTP API. Bind to localhost unless a proxy terminates TLS in front. PORT=8080 # Required. Server-side templates. The only markup the browser ever renders. TEMPLATES_DIR=./templates # Required. Bundled faces. Hashed into the cache key. FONTS_DIR=./fonts # Required. Rendered output by content hash, evicted least recently used. CACHE_DIR=./.cache # Required. The disk ceiling. Eviction is tested against it, not hoped for. CACHE_MAX_BYTES=5368709120 # Required. After this the page is killed, not navigated away from. RENDER_TIMEOUT_MS=15000 # Required. Warm contexts. Beyond this requests queue and then are refused. POOL_SIZE=4 # Required. Hostnames a template may load from. Resolved addresses are checked separately. ASSET_ALLOWLIST=cdn.example.com,images.example.org
# Orshot · indie build A template-driven render service whose hard problems are producing the same pixels on every machine, keeping a browser pool warm and bounded under burst, and never letting a caller's data turn into code the browser runs. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One Node process with an HTTP API and a pool of reusable headless Chromium pages | the browser is the renderer and starting one per request is the whole performance story | | Templates | Server-side folders of HTML, CSS and a declared data schema, versioned in the repository | data is data, markup is code, and the caller supplies only the first | | Fonts | Bundled beside the templates, loaded from disk, hashed into the cache key | a system fallback shifts every line and the output still looks plausible | | Output | Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template | the two paths use different CSS media and pretending otherwise gives you a colourless invoice | | Cache | Content hash of template version, payload and font set, on disk with a size ceiling | the same card is asked for a thousand times and should be drawn once | | Isolation | Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution | an open renderer fetches whatever the caller names, including your metadata endpoint | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the service and the browser driver. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Playwright with Chromium and its system libraries** · free - Why: The renderer. Installing the browser without its system libraries is the most common cause of a service that starts cleanly and renders nothing. - Get it: npm i -D playwright then npx playwright install --with-deps chromium. - Verify: A scripted screenshot of a local page produces a non-empty PNG inside the container - [ ] **Licensed font files you may embed** · free with open licences - Why: Bundling a font you have no licence to redistribute is a legal problem that arrives long after the code works. - Get it: Open-licensed families, or the files your brand licence permits you to embed in a server. - Verify: A written note records each family, its licence and where the file came from - [ ] **A disk you are willing to fill** · pennies per month - Why: The cache and the batch output both live on disk and both grow without a ceiling unless you set one. - Get it: A volume with a known size, separate from the system disk. - Verify: The cache ceiling is set below the volume size and eviction has been observed - [ ] **Your allowlist, timeout and cache ceiling** · free - Why: Each of these is asserted by a test, and choosing them after seeing failures means choosing whatever passes. - Get it: Write down which asset hosts are permitted, how long a render may take, and how much disk the cache may use. - Verify: All three appear in the configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A visual template editor. - Video rendering. - Social publishing. - Caller-supplied HTML. - visual studio editor for non developers - 2,000 plus prebuilt templates - one design auto resized to every social format - scheduled and webhook driven workflows - publishing to social platforms - MCP and agent integrations - white label embedded editor - workspaces, audit logs, and team access If one of those is essential to you, that is the reason to keep paying for Orshot, and the README should say so rather than pretend.
# Build brief · Orshot The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a template render service that turns a JSON payload into an image or a PDF over HTTP to replace Orshot. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Callers send data. They never send markup, a URL, or a path. A render service is a browser you operate on someone else's behalf, and the moment it will load HTML or an address supplied in a request it will fetch your metadata endpoint, your admin page and the contents of /etc, because that is what a browser does when told to. Templates are files in your repository, reviewed and versioned like code. The request body carries values for a schema the template declares, nothing else. Remote assets come from an allowlist, the file scheme is refused, and loopback and private ranges are refused after the hostname is resolved rather than before, because a name can resolve to 127.0.0.1 on the browser's own lookup. ### Stack (fixed, do not substitute) - Runtime: One Node process with an HTTP API and a pool of reusable headless Chromium pages · the browser is the renderer and starting one per request is the whole performance story - Templates: Server-side folders of HTML, CSS and a declared data schema, versioned in the repository · data is data, markup is code, and the caller supplies only the first - Fonts: Bundled beside the templates, loaded from disk, hashed into the cache key · a system fallback shifts every line and the output still looks plausible - Output: Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template · the two paths use different CSS media and pretending otherwise gives you a colourless invoice - Cache: Content hash of template version, payload and font set, on disk with a size ceiling · the same card is asked for a thousand times and should be drawn once - Isolation: Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution · an open renderer fetches whatever the caller names, including your metadata endpoint ### Data model (create this before Phase 1) - templates(name, version, width, height, default_format, schema_json, font_set, updated_at) · version is part of every cache key - renders(id, template, template_version, payload_hash, format, scale, bytes, duration_ms, cache_hit, api_key_id, created_at) - assets(pattern, kind, sha256, allowed) · every remote image a template may load, checked before a page is opened - fonts(family, weight, style, file, sha256) · hashed into the cache key so replacing a font invalidates only what used it - api_keys(id, label, hashed_key, rate_per_minute, monthly_render_cap, created_at, revoked_at) - failures(id, template, payload_hash, stage, message, console_excerpt, at) · the page's console output, because most failures are a missing asset nobody saw - The cache key is the hash of template version, payload and font set together, so an identical request never opens a browser and editing one template invalidates nothing else. ### Phase 1 · One template that renders the same everywhere Build: A template folder with index.html, style.css and meta.json declaring width, height, scale and a data schema. Bundle the fonts beside it and load them from disk. Set the viewport from meta.json, wait until the document reports its fonts ready and its images decoded, then screenshot. Add a golden-image test that fails on a pixel difference above a stated threshold. Trap: Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. Done when: A payload rendered on the host and inside the container produces images within the stated pixel threshold, tested in CI. The computed font family of every text node matches a bundled face, asserted rather than eyeballed. Removing a bundled font file fails the render loudly instead of falling back. The declared scale produces the expected pixel dimensions for both scale one and scale two. Do not build yet: No API keys, caching, PDF, batching or a browser pool. ### Phase 2 · Data in, never markup Build: Validate the payload against the template's declared schema and reject unknown keys. Escape every interpolated value so it can only become text or an attribute value. Resolve remote asset addresses against an allowlist, refuse the file scheme, and after resolving the hostname refuse loopback and private ranges. Run the browser with no access to the host filesystem beyond the template directory. Trap: Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. Done when: A payload containing a script tag renders as visible text and executes nothing, tested. An unknown key in the payload is rejected with the offending key named. A template asked for an image on an address outside the allowlist fails the render with a clear reason. A hostname that resolves into a private range is refused, tested with a controlled DNS entry. Do not build yet: No PDF, browser pool, caching or keys yet. ### Phase 3 · PDFs that look like the design Build: Generate PDFs through the browser's print path, with the page size taken from meta.json in real units. Playwright's own documentation says page.pdf generates a PDF with print CSS media and that by default it produces modified colours for printing, recommending print-color-adjust to force exact colours (checked). So decide per template whether to emulate screen media or write print styles, and set the colour adjustment explicitly. Handle page breaks and a multi-page document. Trap: Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. Done when: A template with a coloured background produces a PDF containing that colour, asserted by sampling the rasterised page. The page size in the PDF matches meta.json in millimetres, not in pixels. A two-page document breaks where the template says and not mid-row. The golden test covers the PDF path as well as the image path. Do not build yet: No pool, cache, keys or batching yet. ### Phase 4 · A browser pool that survives Monday Build: Keep a small pool of browser contexts warm, one fresh context per render so state cannot leak between callers. Cap concurrency, queue beyond it with a bounded depth, and answer with a retry-after once the queue is full rather than accepting work you will not do. Give every render a hard timeout that kills the page, and restart a browser after a stated number of renders or when memory crosses a ceiling. Trap: A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. Done when: A render that hangs is killed at the stated timeout and the worker is back in the pool, tested with a template that waits on a dead address. Two hundred concurrent requests produce completed renders and explicit queue-full responses, never a crash, tested. Memory after a thousand renders is within a stated band, measured. One caller's payload never appears in another caller's output, because each render gets a fresh context. Do not build yet: No cache, keys, budgets or logs yet. ### Phase 5 · Caching, keys and a bill that cannot run away Build: Cache by the hash of template version, payload and font set, with an on-disk ceiling and least-recently-used eviction. Serve a hit without opening a browser and say so in a header. Issue API keys with a per-minute rate and a monthly render cap, refusing past the cap rather than billing through it. Log every render with its template, duration, cache status and key. Add a batch endpoint that streams a manifest and honours the same caps. Trap: Caching on the payload alone means editing a template or swapping a font silently serves last week's picture, and the person who made the edit assumes it did not deploy. Put the template version and a hash of the font set in the key, and invalidate by writing a new key rather than by clearing the cache. Done when: A repeated request is served from disk without opening a browser, verified by the render count and the response header. Editing a template changes its version and the next request renders fresh, tested. A key past its monthly cap is refused with a clear reason and the refusal is logged. The cache stays under its disk ceiling under a generated workload, measured rather than assumed. Do not build yet: Nothing after this. ### Out of scope (and why) - A visual editor for non-developers, which is the actual product being replaced here and is a design tool with a rendering pipeline attached, not a weekend. - Video rendering, which needs a frame pipeline, an encoder and a job model that has nothing in common with taking a screenshot. - Publishing to social platforms, since each one is an application review, a token lifecycle and a set of rules you must read. - Accepting HTML from callers, which is the one feature that would turn this into an open browser on the internet. ### README must contain - That templates live in the repository and callers send only data validated against a declared schema - How remote assets are allowlisted, and that the resolved address is checked rather than the hostname - That fonts are bundled and hashed into the cache key, so replacing one invalidates exactly what used it - That PDFs go through the print path, which applies print CSS media and adjusts colours unless told otherwise - How to add a template, with the golden-image test that must accompany it, and the container line that installs the Chromium system libraries
# Agent instructions · Orshot indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One Node process with an HTTP API and a pool of reusable headless Chromium pages, Server-side folders of HTML, CSS and a declared data schema, versioned in the repository, Bundled beside the templates, loaded from disk, hashed into the cache key, Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template, Content hash of template version, payload and font set, on disk with a size ceiling, Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser.
# Build plan · Orshot A template-driven render service whose hard problems are producing the same pixels on every machine, keeping a browser pool warm and bounded under burst, and never letting a caller's data turn into code the browser runs. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One template that renders the same everywhere The same payload produces the same pixels on your laptop and in a container. ### Steps 1. Declare size, scale and schema in meta.json The template states what it needs so the service can reject a payload before opening a browser. Files: `templates/og-card/meta.json`, `src/templates.ts` 2. Wait for fonts and images, not for load Load fires long before the page looks like the design. Files: `src/render.ts` 3. Add a golden-image test with a threshold A rendering regression is invisible in a diff and obvious in a picture. Files: `tests/golden.test.ts`, `tests/golden/og-card.png` ### Done when - [ ] Host and container renders match within the threshold - [ ] Every text node resolves to a bundled font - [ ] A missing font file fails loudly - [ ] Declared scale produces the expected dimensions ### Watch out - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. ## Phase 2 · Data in, never markup A hostile payload cannot make the browser fetch or run anything you did not choose. ### Steps 1. Validate against the declared schema Unknown keys are rejected, not ignored, so a typo is an error rather than a blank. Files: `src/validate.ts`, `tests/schema.test.ts` 2. Escape every interpolation by default Raw insertion should not be reachable from a request at all. Files: `src/render.ts` 3. Check the resolved address, not the hostname This is the difference between an allowlist and the appearance of one. Files: `src/assets.ts`, `tests/ssrf.test.ts` ### Done when - [ ] A script tag in the payload renders as text - [ ] Unknown payload keys are rejected by name - [ ] Off-allowlist assets fail the render - [ ] A hostname resolving to a private range is refused ### Watch out - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. ## Phase 3 · PDFs that look like the design The invoice on screen and the invoice in the file are the same document. ### Steps 1. Choose the CSS media per template and record it Print and screen are different documents and the template should say which it is. Files: `templates/invoice/meta.json`, `src/pdf.ts` 2. Force the colour adjustment explicitly The default is tuned for ink, and your design is not. Files: `templates/invoice/style.css` 3. Rasterise the PDF in the golden test Comparing bytes tells you nothing; comparing pixels tells you everything. Files: `tests/golden-pdf.test.ts` ### Done when - [ ] Background colours survive into the PDF - [ ] Page size matches meta.json in real units - [ ] Page breaks land where the template says - [ ] The golden test covers the PDF path ### Watch out - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. ## Phase 4 · A browser pool that survives Monday Burst traffic queues instead of exhausting memory, and a hung page never holds a worker. ### Steps 1. Warm pool, fresh context per render Reuse the expensive part, discard the part that holds state. Files: `src/pool.ts` 2. Bound the queue and say no Accepting work you cannot finish turns a slow minute into an outage. Files: `src/queue.ts`, `tests/load.test.ts` 3. Timeout, kill, recycle Measure memory over a thousand renders and set the recycle point from the measurement. Files: `src/pool.ts`, `tests/timeout.test.ts` ### Done when - [ ] A hung render is killed and the worker returns - [ ] Overload produces queue-full responses, not a crash - [ ] Memory after a thousand renders stays in band - [ ] No state leaks between renders ### Watch out - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. ## Not in this build - A visual template editor. - Video rendering. - Social publishing. - Caller-supplied HTML. ## After v1, if you want it - Signed render URLs so a template and payload can be embedded in a page without exposing a key - A second output size derived from the same template, once the golden tests cover the first - A preview endpoint that renders at low scale for editing, sharing the cache with the full-size path
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The HTTP API. Bind to localhost unless a proxy terminates TLS in front. PORT=8080 # Required. Server-side templates. The only markup the browser ever renders. TEMPLATES_DIR=./templates # Required. Bundled faces. Hashed into the cache key. FONTS_DIR=./fonts # Required. Rendered output by content hash, evicted least recently used. CACHE_DIR=./.cache # Required. The disk ceiling. Eviction is tested against it, not hoped for. CACHE_MAX_BYTES=5368709120 # Required. After this the page is killed, not navigated away from. RENDER_TIMEOUT_MS=15000 # Required. Warm contexts. Beyond this requests queue and then are refused. POOL_SIZE=4 # Required. Hostnames a template may load from. Resolved addresses are checked separately. ASSET_ALLOWLIST=cdn.example.com,images.example.org
# Orshot · product brief ## Problem The render itself is not the moat. Headless Chromium plus a Handlebars template gets you a PNG or a PDF from JSON in an afternoon, and for one team rendering its own OG images that really is the whole job. What does not fall out of a weekend is everything around the render: a visual editor non engineers can use, one design that re solves cleanly into a story, a square, and an OG card, workflows that fire on a schedule or a webhook against Sheets and Airtable, publishing into 15 plus social platforms, and a render farm that stays warm and predictable under burst. Build it if you own the templates and the volume is small. Keep paying if the people making the creatives do not write HTML. ## Product outcome A render service that produces identical output on any machine, refuses anything a caller tries to make it fetch or run, keeps its browsers warm and bounded, and cannot be made expensive by a client stuck in a retry loop. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - headless Chromium (Playwright or Puppeteer) - a template language and a place to store templates - a warm browser pool and a render queue - font and brand asset handling - object storage plus a CDN for delivery - a host that tolerates burst traffic ## Explicit non-goals for v1 - A visual template editor. - Video rendering. - Social publishing. - Caller-supplied HTML. - visual studio editor for non developers - 2,000 plus prebuilt templates - one design auto resized to every social format - scheduled and webhook driven workflows - publishing to social platforms - MCP and agent integrations - white label embedded editor - workspaces, audit logs, and team access ## Success criteria - Host and container renders are confirmed to match within the threshold - A script tag in the payload is confirmed to render as text - A hostname resolving to a private range is confirmed refused - PDF colour and page size are confirmed against the design - A hung render is confirmed killed and the worker returned
# Build brief · Orshot The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a template render service that turns a JSON payload into an image or a PDF over HTTP to replace Orshot. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Callers send data. They never send markup, a URL, or a path. A render service is a browser you operate on someone else's behalf, and the moment it will load HTML or an address supplied in a request it will fetch your metadata endpoint, your admin page and the contents of /etc, because that is what a browser does when told to. Templates are files in your repository, reviewed and versioned like code. The request body carries values for a schema the template declares, nothing else. Remote assets come from an allowlist, the file scheme is refused, and loopback and private ranges are refused after the hostname is resolved rather than before, because a name can resolve to 127.0.0.1 on the browser's own lookup. ### Stack (fixed, do not substitute) - Runtime: One Node process with an HTTP API and a pool of reusable headless Chromium pages · the browser is the renderer and starting one per request is the whole performance story - Templates: Server-side folders of HTML, CSS and a declared data schema, versioned in the repository · data is data, markup is code, and the caller supplies only the first - Fonts: Bundled beside the templates, loaded from disk, hashed into the cache key · a system fallback shifts every line and the output still looks plausible - Output: Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template · the two paths use different CSS media and pretending otherwise gives you a colourless invoice - Cache: Content hash of template version, payload and font set, on disk with a size ceiling · the same card is asked for a thousand times and should be drawn once - Isolation: Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution · an open renderer fetches whatever the caller names, including your metadata endpoint ### Data model (create this before Phase 1) - templates(name, version, width, height, default_format, schema_json, font_set, updated_at) · version is part of every cache key - renders(id, template, template_version, payload_hash, format, scale, bytes, duration_ms, cache_hit, api_key_id, created_at) - assets(pattern, kind, sha256, allowed) · every remote image a template may load, checked before a page is opened - fonts(family, weight, style, file, sha256) · hashed into the cache key so replacing a font invalidates only what used it - api_keys(id, label, hashed_key, rate_per_minute, monthly_render_cap, created_at, revoked_at) - failures(id, template, payload_hash, stage, message, console_excerpt, at) · the page's console output, because most failures are a missing asset nobody saw - The cache key is the hash of template version, payload and font set together, so an identical request never opens a browser and editing one template invalidates nothing else. ### Phase 1 · One template that renders the same everywhere Build: A template folder with index.html, style.css and meta.json declaring width, height, scale and a data schema. Bundle the fonts beside it and load them from disk. Set the viewport from meta.json, wait until the document reports its fonts ready and its images decoded, then screenshot. Add a golden-image test that fails on a pixel difference above a stated threshold. Trap: Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. Done when: A payload rendered on the host and inside the container produces images within the stated pixel threshold, tested in CI. The computed font family of every text node matches a bundled face, asserted rather than eyeballed. Removing a bundled font file fails the render loudly instead of falling back. The declared scale produces the expected pixel dimensions for both scale one and scale two. Do not build yet: No API keys, caching, PDF, batching or a browser pool. ### Phase 2 · Data in, never markup Build: Validate the payload against the template's declared schema and reject unknown keys. Escape every interpolated value so it can only become text or an attribute value. Resolve remote asset addresses against an allowlist, refuse the file scheme, and after resolving the hostname refuse loopback and private ranges. Run the browser with no access to the host filesystem beyond the template directory. Trap: Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. Done when: A payload containing a script tag renders as visible text and executes nothing, tested. An unknown key in the payload is rejected with the offending key named. A template asked for an image on an address outside the allowlist fails the render with a clear reason. A hostname that resolves into a private range is refused, tested with a controlled DNS entry. Do not build yet: No PDF, browser pool, caching or keys yet. ### Phase 3 · PDFs that look like the design Build: Generate PDFs through the browser's print path, with the page size taken from meta.json in real units. Playwright's own documentation says page.pdf generates a PDF with print CSS media and that by default it produces modified colours for printing, recommending print-color-adjust to force exact colours (checked). So decide per template whether to emulate screen media or write print styles, and set the colour adjustment explicitly. Handle page breaks and a multi-page document. Trap: Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. Done when: A template with a coloured background produces a PDF containing that colour, asserted by sampling the rasterised page. The page size in the PDF matches meta.json in millimetres, not in pixels. A two-page document breaks where the template says and not mid-row. The golden test covers the PDF path as well as the image path. Do not build yet: No pool, cache, keys or batching yet. ### Phase 4 · A browser pool that survives Monday Build: Keep a small pool of browser contexts warm, one fresh context per render so state cannot leak between callers. Cap concurrency, queue beyond it with a bounded depth, and answer with a retry-after once the queue is full rather than accepting work you will not do. Give every render a hard timeout that kills the page, and restart a browser after a stated number of renders or when memory crosses a ceiling. Trap: A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. Done when: A render that hangs is killed at the stated timeout and the worker is back in the pool, tested with a template that waits on a dead address. Two hundred concurrent requests produce completed renders and explicit queue-full responses, never a crash, tested. Memory after a thousand renders is within a stated band, measured. One caller's payload never appears in another caller's output, because each render gets a fresh context. Do not build yet: No cache, keys, budgets or logs yet. ### Phase 5 · Caching, keys and a bill that cannot run away Build: Cache by the hash of template version, payload and font set, with an on-disk ceiling and least-recently-used eviction. Serve a hit without opening a browser and say so in a header. Issue API keys with a per-minute rate and a monthly render cap, refusing past the cap rather than billing through it. Log every render with its template, duration, cache status and key. Add a batch endpoint that streams a manifest and honours the same caps. Trap: Caching on the payload alone means editing a template or swapping a font silently serves last week's picture, and the person who made the edit assumes it did not deploy. Put the template version and a hash of the font set in the key, and invalidate by writing a new key rather than by clearing the cache. Done when: A repeated request is served from disk without opening a browser, verified by the render count and the response header. Editing a template changes its version and the next request renders fresh, tested. A key past its monthly cap is refused with a clear reason and the refusal is logged. The cache stays under its disk ceiling under a generated workload, measured rather than assumed. Do not build yet: Nothing after this. ### Out of scope (and why) - A visual editor for non-developers, which is the actual product being replaced here and is a design tool with a rendering pipeline attached, not a weekend. - Video rendering, which needs a frame pipeline, an encoder and a job model that has nothing in common with taking a screenshot. - Publishing to social platforms, since each one is an application review, a token lifecycle and a set of rules you must read. - Accepting HTML from callers, which is the one feature that would turn this into an open browser on the internet. ### README must contain - That templates live in the repository and callers send only data validated against a declared schema - How remote assets are allowlisted, and that the resolved address is checked rather than the hostname - That fonts are bundled and hashed into the cache key, so replacing one invalidates exactly what used it - That PDFs go through the print path, which applies print CSS media and adjusts colours unless told otherwise - How to add a template, with the golden-image test that must accompany it, and the container line that installs the Chromium system libraries
# Architecture · Orshot ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One Node process with an HTTP API and a pool of reusable headless Chromium pages | the browser is the renderer and starting one per request is the whole performance story | | Templates | Server-side folders of HTML, CSS and a declared data schema, versioned in the repository | data is data, markup is code, and the caller supplies only the first | | Fonts | Bundled beside the templates, loaded from disk, hashed into the cache key | a system fallback shifts every line and the output still looks plausible | | Output | Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template | the two paths use different CSS media and pretending otherwise gives you a colourless invoice | | Cache | Content hash of template version, payload and font set, on disk with a size ceiling | the same card is asked for a thousand times and should be drawn once | | Isolation | Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution | an open renderer fetches whatever the caller names, including your metadata endpoint | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Templates | Markup, styles and a declared schema, versioned. | Any template language, provided callers still send only data. | | Assets | Allowlisting and address checks before the browser opens. | A fetching proxy instead of in-process checks, provided the resolved address is what is checked. | | Renderer | Screenshot and print paths, font readiness, scale. | Another browser driver, provided the golden tests still pass. | | Pool | Warm contexts, timeouts, recycling, the bounded queue. | An external worker pool, provided overload still refuses rather than accepts. | | Cache | Content hashing, eviction, the disk ceiling. | Object storage, provided the key still includes template version and fonts. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · The HTTP API. Bind to localhost unless a proxy terminates TLS in front. - `TEMPLATES_DIR` · required · Server-side templates. The only markup the browser ever renders. - `FONTS_DIR` · required · Bundled faces. Hashed into the cache key. - `CACHE_DIR` · required · Rendered output by content hash, evicted least recently used. - `CACHE_MAX_BYTES` · required · The disk ceiling. Eviction is tested against it, not hoped for. - `RENDER_TIMEOUT_MS` · required · After this the page is killed, not navigated away from. - `POOL_SIZE` · required · Warm contexts. Beyond this requests queue and then are refused. - `ASSET_ALLOWLIST` · required · Hostnames a template may load from. Resolved addresses are checked separately. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Orshot product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One Node process with an HTTP API and a pool of reusable headless Chromium pages, Server-side folders of HTML, CSS and a declared data schema, versioned in the repository, Bundled beside the templates, loaded from disk, hashed into the cache key, Screenshot for PNG and JPEG, the print path for PDF, dimensions declared per template, Content hash of template version, payload and font set, on disk with a size ceiling, Allowlisted remote assets, no file scheme, private ranges refused after DNS resolution. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. - Caching on the payload alone means editing a template or swapping a font silently serves last week's picture, and the person who made the edit assumes it did not deploy. Put the template version and a hash of the font set in the key, and invalidate by writing a new key rather than by clearing the cache.
# Delivery milestones · Orshot Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One template that renders the same everywhere The same payload produces the same pixels on your laptop and in a container. ### Steps 1. Declare size, scale and schema in meta.json The template states what it needs so the service can reject a payload before opening a browser. Files: `templates/og-card/meta.json`, `src/templates.ts` 2. Wait for fonts and images, not for load Load fires long before the page looks like the design. Files: `src/render.ts` 3. Add a golden-image test with a threshold A rendering regression is invisible in a diff and obvious in a picture. Files: `tests/golden.test.ts`, `tests/golden/og-card.png` ### Done when - [ ] Host and container renders match within the threshold - [ ] Every text node resolves to a bundled font - [ ] A missing font file fails loudly - [ ] Declared scale produces the expected dimensions ### Watch out - Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it. ## M2 · Data in, never markup A hostile payload cannot make the browser fetch or run anything you did not choose. ### Steps 1. Validate against the declared schema Unknown keys are rejected, not ignored, so a typo is an error rather than a blank. Files: `src/validate.ts`, `tests/schema.test.ts` 2. Escape every interpolation by default Raw insertion should not be reachable from a request at all. Files: `src/render.ts` 3. Check the resolved address, not the hostname This is the difference between an allowlist and the appearance of one. Files: `src/assets.ts`, `tests/ssrf.test.ts` ### Done when - [ ] A script tag in the payload renders as text - [ ] Unknown payload keys are rejected by name - [ ] Off-allowlist assets fail the render - [ ] A hostname resolving to a private range is refused ### Watch out - Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks. ## M3 · PDFs that look like the design The invoice on screen and the invoice in the file are the same document. ### Steps 1. Choose the CSS media per template and record it Print and screen are different documents and the template should say which it is. Files: `templates/invoice/meta.json`, `src/pdf.ts` 2. Force the colour adjustment explicitly The default is tuned for ink, and your design is not. Files: `templates/invoice/style.css` 3. Rasterise the PDF in the golden test Comparing bytes tells you nothing; comparing pixels tells you everything. Files: `tests/golden-pdf.test.ts` ### Done when - [ ] Background colours survive into the PDF - [ ] Page size matches meta.json in real units - [ ] Page breaks land where the template says - [ ] The golden test covers the PDF path ### Watch out - Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path. ## M4 · A browser pool that survives Monday Burst traffic queues instead of exhausting memory, and a hung page never holds a worker. ### Steps 1. Warm pool, fresh context per render Reuse the expensive part, discard the part that holds state. Files: `src/pool.ts` 2. Bound the queue and say no Accepting work you cannot finish turns a slow minute into an outage. Files: `src/queue.ts`, `tests/load.test.ts` 3. Timeout, kill, recycle Measure memory over a thousand renders and set the recycle point from the measurement. Files: `src/pool.ts`, `tests/timeout.test.ts` ### Done when - [ ] A hung render is killed and the worker returns - [ ] Overload produces queue-full responses, not a crash - [ ] Memory after a thousand renders stays in band - [ ] No state leaks between renders ### Watch out - A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser. ## M5 · Caching, keys and a bill that cannot run away (production only) Identical work is done once, every caller is identified, and a loop cannot cost you a month of compute. ### Steps 1. Hash template version, payload and fonts together One key covering everything that changes the picture. Files: `src/cache.ts`, `tests/cache-key.test.ts` 2. Cap per key, per month, and refuse A caller in a retry loop is the most likely source of a large bill. Files: `src/keys.ts` 3. Log template, duration and cache status per render Without it you cannot tell a slow template from a cold cache. Files: `src/log.ts` ### Done when - [ ] Repeat requests never open a browser - [ ] A template edit invalidates its own cache only - [ ] A key past its cap is refused and logged - [ ] Cache disk stays under the ceiling ### Watch out - Caching on the payload alone means editing a template or swapping a font silently serves last week's picture, and the person who made the edit assumes it did not deploy. Put the template version and a hash of the font set in the key, and invalidate by writing a new key rather than by clearing the cache.
# Operations · Orshot ## Backup Templates and fonts are in the repository, which is the backup. The cache is disposable by design and should never hold the only copy of anything, so a batch job writes its output somewhere durable rather than into the cache directory. ## Restore Restoring is a redeploy plus a cold cache, and the only thing to verify is that the golden tests pass on the restored host, since a missing system library for the browser produces blank images rather than errors. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Render duration by template, cache hit rate, queue depth, timeout count and browser recycles. A rising timeout count is the early signal that a template is waiting on an asset that has gone away. ## Incident checklist A blank or wrong-looking image is nearly always a missing font, a missing system library or an asset that failed to load, and the failures table keeps the page console excerpt precisely so that question takes a minute rather than an afternoon. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Host and container renders are confirmed to match within the threshold - [ ] A script tag in the payload is confirmed to render as text - [ ] A hostname resolving to a private range is confirmed refused - [ ] PDF colour and page size are confirmed against the design - [ ] A hung render is confirmed killed and the worker returned ## Launch constraint Do not market omitted Orshot capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The HTTP API. Bind to localhost unless a proxy terminates TLS in front. PORT=8080 # Required. Server-side templates. The only markup the browser ever renders. TEMPLATES_DIR=./templates # Required. Bundled faces. Hashed into the cache key. FONTS_DIR=./fonts # Required. Rendered output by content hash, evicted least recently used. CACHE_DIR=./.cache # Required. The disk ceiling. Eviction is tested against it, not hoped for. CACHE_MAX_BYTES=5368709120 # Required. After this the page is killed, not navigated away from. RENDER_TIMEOUT_MS=15000 # Required. Warm contexts. Beyond this requests queue and then are refused. POOL_SIZE=4 # Required. Hostnames a template may load from. Resolved addresses are checked separately. ASSET_ALLOWLIST=cdn.example.com,images.example.org
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because the person who needs the graphic is usually not the person who can maintain a Chromium pool, and the value shows up when marketing edits a template without filing a ticket.
xvisual studio editor for non developers
x2,000 plus prebuilt templates
xone design auto resized to every social format
xscheduled and webhook driven workflows
xpublishing to social platforms
xMCP and agent integrations
xwhite label embedded editor
xworkspaces, audit logs, and team access
Don't feel like building it? These folks already made it free.
all 4 free alternatives to Orshot →· no votes, no pay-to-list · just what's real
Orshot pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0 | $0 | 30 one-time render credits; 50 one-time AI credits; 100 requests/minute; 1 workspace and 1 member. |
| launch 1,500 | $39 | $32.50 | 1,500 render credits/month; 100 AI credits; 200 automation runs; 3 workflows; 1 workspace; 2 members. |
| launch 5,000 | $99 | $82.50 | 5,000 render credits/month; 200 AI credits; 2 workspaces; 4 members. |
| grow 20,000 | $160 | $133.33 | 20,000 render credits/month; 400 AI credits; 800 automation runs; 10 workflows; 4 workspaces; 6 members. |
| grow 40,000 | $280 | $233.33 | 40,000 render credits/month; 800 AI credits; 5 workspaces; 8 members. |
| scale 75,000 | $349 | $290.83 | 75,000 render credits/month; 1,200 AI credits; 2,500 automation runs; 30 workflows; 10 workspaces; 14 members. |
| scale 150,000 | $480 | $400 | 150,000 render credits/month; 2,000 AI credits; 15 workspaces; 18 members. |
| scale 300,000 | $600 | $500 | 300,000 render credits/month; 3,000 AI credits; 18 workspaces; 24 members. |
| enterprise | custom | — | Custom render, automation, workspace, API and support limits. |
free tier30 render credits and 50 AI credits one time, 100 requests/minute, 1 workspace and 1 member
billingmonthly + annual; annual includes 2 free months
hidden costs1 image, PDF page or video second consumes 1 render credit. Pay-as-you-go overages are $36, $30 or $24 per 1,000 by tier; Social Publishing is a separate $12/month per connected social account.
verified 2026-08-11 · source ↗
Is Orshot free?
30 render credits and 50 AI credits, no card required, enough to evaluate but not to run anything recurring. Paid is Launch at $39/mo (checked 2026-08-09).
Vibecode Orshot
Kinda. The core of Orshot is buildable in a weekend with the prompt on this page, but there are real gaps: visual studio editor for non developers, 2,000 plus prebuilt templates. Read the honest list above before committing.
How much does Orshot cost?
Orshot costs about $39/month (Launch, checked 2026-08-09), which is $468 per year.
What do I lose by replacing Orshot?
Honestly: visual studio editor for non developers; 2,000 plus prebuilt templates; one design auto resized to every social format; scheduled and webhook driven workflows; publishing to social platforms; MCP and agent integrations; white label embedded editor; workspaces, audit logs, and team access. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Orshot?
Yes: Gotenberg (A Docker API that turns HTML into PDFs and screenshots, so you bring the templates and it handles the Chromium wrangling you do not want to own.) Cloudinary Free (Free forever with 25 monthly credits; text and image overlays are URL parameters, which covers product shots and simple cards without a render server.) ImageKit Free (Forever free at 20 GB bandwidth with all transformations included, so layered text over a base image is a URL rather than a pipeline.) All 4 curated free alternatives are at vibecodeit.com/orshot/alternatives. The prompt is for when you want it exactly your way.