The mechanism is simpler and more brutal than people expect: the build never runs your code. It reads your files as text.
At build time your source is scanned for anything that looks like a class name. Every string it finds that matches a known pattern gets a rule generated. That is the entire process — there is no evaluation, no type information, no understanding of your components.
So when you write a template string that produces bg-red-500 at runtime, the scanner sees the literal text in your file. It sees bg- and it sees a variable. It never sees bg-red-500, because that string does not exist anywhere in your source. No rule is generated, and the class on the element points at nothing.
Why it works in development: dev typically generates rules on demand or ships a much broader set, so the rule happens to exist. Production generates only what was found. This is exactly why the failure appears at deploy time, which is the worst moment to discover it.
The consequence, and it is the rule to carry away: every class you use must appear complete and unbroken somewhere in a scanned file. It does not have to be in the element that uses it. It does have to exist as literal text.
Once you have that, the fixes below are obvious rather than magic.