<?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: Rakesh Paul</title>
    <description>The latest articles on DEV Community by Rakesh Paul (@rakesh_paul).</description>
    <link>https://dev.to/rakesh_paul</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%2F201316%2Fceb07d6a-e533-4e19-b791-eec6e7f57554.jpg</url>
      <title>DEV Community: Rakesh Paul</title>
      <link>https://dev.to/rakesh_paul</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rakesh_paul"/>
    <language>en</language>
    <item>
      <title>TLS for Postgres on Railway with Let's Encrypt</title>
      <dc:creator>Rakesh Paul</dc:creator>
      <pubDate>Sun, 01 Nov 2026 13:02:47 +0000</pubDate>
      <link>https://dev.to/rakesh_paul/tls-for-postgres-on-railway-with-lets-encrypt-3fmo</link>
      <guid>https://dev.to/rakesh_paul/tls-for-postgres-on-railway-with-lets-encrypt-3fmo</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://binaryroute.com/blog/railway-postgres-tls-lets-encrypt/" rel="noopener noreferrer"&gt;BinaryRoute&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: A Public Port Needs Verified TLS
&lt;/h2&gt;

&lt;p&gt;Railway gives each service a private network. Your app talks to Postgres over it, and nothing leaves the project. Then one day you want to reach the database from your laptop: a &lt;code&gt;pg_dump&lt;/code&gt;, a SQL client, a sync script. You switch on &lt;strong&gt;TCP Proxy&lt;/strong&gt;, get a &lt;code&gt;something.proxy.rlwy.net:NNNNN&lt;/code&gt; address, and it just works.&lt;/p&gt;

&lt;p&gt;The proxy forwards raw bytes; it does not add PostgreSQL TLS. My custom pgvector deployment did not offer TLS either. Asking it for TLS returned no:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probing the proxy&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;openssl s_client &lt;span class="nt"&gt;-starttls&lt;/span&gt; postgres &lt;span class="nt"&gt;-connect&lt;/span&gt; xxxx.proxy.rlwy.net:NNNNN
&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Postgres answers &lt;span class="s1"&gt;'N'&lt;/span&gt; to the SSLRequest: no TLS on offer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a server with TLS disabled, everything that crosses that port is in clear text:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your data.&lt;/strong&gt; Queries, results and whole dumps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your login, in a sense.&lt;/strong&gt; &lt;code&gt;scram-sha-256&lt;/code&gt; never sends the password itself. But a recorded exchange can be brute-forced offline, and without TLS there's no channel binding, so a man in the middle can relay the exchange and keep the session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is the runbook I used on a custom pgvector service. Postgres gets a Let's Encrypt certificate, and TLS becomes &lt;strong&gt;required&lt;/strong&gt; for every connection from outside the private network. Railway's current official Postgres image already enables TLS and includes pgvector, so start with the image check below before changing a start command.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Everything below uses placeholders: &lt;code&gt;db.example.com&lt;/code&gt; for the hostname, &lt;code&gt;xxxx.proxy.rlwy.net:NNNNN&lt;/code&gt; for the proxy, and &lt;code&gt;fd12:3456:789a::/48&lt;/code&gt; for the private range. Measure and substitute your own.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  First: Check Your Image
&lt;/h2&gt;

&lt;p&gt;In Railway, record the service's &lt;strong&gt;image tag and digest, entrypoint, and start command&lt;/strong&gt;. In its console, inspect the running configuration:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Railway console: identify the TLS setup&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; railway &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SHOW ssl;'&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SHOW ssl_cert_file;'&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SHOW ssl_ca_file;'&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SHOW hba_file;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Using the official Railway Postgres template?&lt;/p&gt;

&lt;p&gt;It already offers TLS with its own CA and includes pgvector. Public roots do not trust that CA, and its generated certificate names localhost and, when configured, the private service domain—not your public hostname. Connecting to your public hostname with public roots and &lt;code&gt;sslmode=verify-full&lt;/code&gt; will therefore fail. Its startup renewal can also replace the CA, so copying that CA once is not a lasting setup.&lt;/p&gt;

&lt;p&gt;Keep its entrypoint and wrapper. See &lt;a href="https://docs.railway.com/databases/postgresql" rel="noopener noreferrer"&gt;Railway’s Postgres documentation&lt;/a&gt; and the &lt;a href="https://github.com/railwayapp-templates/postgres-ssl" rel="noopener noreferrer"&gt;image repository&lt;/a&gt; for that image. The runbook below covers custom images.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Custom pgvector Runbook
&lt;/h2&gt;

&lt;p&gt;This is the setup I tested: a &lt;code&gt;pgvector/pgvector&lt;/code&gt; image using the official Docker Postgres entrypoint, with TLS initially off and no additional lifecycle wrapper. Confirm that shape before using these commands. Keep your existing PostgreSQL major version and image.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Shape of the Fix
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhb1vtbssp1nowrpsgtdg.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhb1vtbssp1nowrpsgtdg.png" alt="A laptop connects through the Railway TCP proxy to Postgres with TLS. DNS resolves the public hostname; the app uses a separate private-network connection." width="800" height="758"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The dotted link is a DNS lookup, not a hop in the TLS session. The CNAME resolves to the TCP proxy; database traffic flows from the laptop through that proxy to Postgres. The app's separate connection reaches Postgres over Railway's encrypted private network.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Runbook at a Glance
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;STEP 0: Record the current state.&lt;/strong&gt; Start command and pg_hba rules: your rollback values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STEP 1: Issue the certificate.&lt;/strong&gt; lego + Cloudflare DNS-01, on your own machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STEP 2: Store it in Railway.&lt;/strong&gt; Base64 cert and key in two service variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STEP 3: Offer TLS.&lt;/strong&gt; New start command, then measure where the app connects from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STEP 4: Require TLS.&lt;/strong&gt; A boot-time pg_hba: plaintext only on the private range.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STEP 5–6: Open the proxy, then verify.&lt;/strong&gt; CNAME, chain check, plaintext refused, real login.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TLS goes on in two deploys, &lt;strong&gt;offer&lt;/strong&gt; and then &lt;strong&gt;require&lt;/strong&gt;, on purpose. Between them you measure the addresses your app really connects from, so the rule that keeps it working is based on data, not a guess.&lt;/p&gt;




&lt;p&gt;Four decisions make this work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A hostname you own&lt;/strong&gt; (&lt;code&gt;db.example.com&lt;/code&gt;, a CNAME to the proxy)&lt;/td&gt;
&lt;td&gt;A public CA will only certify a domain you control, and you don't control &lt;code&gt;*.proxy.rlwy.net&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Let's Encrypt via DNS-01&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nothing serves HTTP on a database hostname, so HTTP-01 can't work. DNS-01 proves control with a TXT record instead.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Cert and key in Railway variables&lt;/strong&gt;, written to &lt;code&gt;/tmp/ssl&lt;/code&gt; at boot&lt;/td&gt;
&lt;td&gt;Nothing touches the data volume. Rollback is just configuration.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A boot-time &lt;code&gt;pg_hba.conf&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ssl=on&lt;/code&gt; only &lt;em&gt;offers&lt;/em&gt; TLS. A client with &lt;code&gt;sslmode=disable&lt;/code&gt; still connects. The &lt;code&gt;pg_hba&lt;/code&gt; rules are what &lt;em&gt;require&lt;/em&gt; it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Let's Encrypt or your own CA?
&lt;/h3&gt;

&lt;p&gt;Both work with the same steps. The difference is where the work goes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Let's Encrypt&lt;/th&gt;
&lt;th&gt;Your own CA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trust on the client&lt;/td&gt;
&lt;td&gt;Public roots, explicitly enabled where needed&lt;/td&gt;
&lt;td&gt;Copy &lt;code&gt;ca.crt&lt;/code&gt; to every client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate life&lt;/td&gt;
&lt;td&gt;90 days, and getting shorter&lt;/td&gt;
&lt;td&gt;Your choice, e.g. 2 years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renewal&lt;/td&gt;
&lt;td&gt;Based on expiry/ARI: renew, update 2 variables, redeploy&lt;/td&gt;
&lt;td&gt;Before the leaf certificate expires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exposure&lt;/td&gt;
&lt;td&gt;Hostname appears in public Certificate Transparency logs&lt;/td&gt;
&lt;td&gt;Nothing public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prerequisites&lt;/td&gt;
&lt;td&gt;A DNS provider with an API, a scoped token, &lt;code&gt;lego&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Just &lt;code&gt;openssl&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I picked Let's Encrypt because clients can use public roots, with the appropriate trust configuration. A private CA reuses the server setup but needs its root installed on every client and its own issuance/renewal procedure; skip the lego commands in that case.&lt;/p&gt;




&lt;h2&gt;
  
  
  Before You Start
&lt;/h2&gt;

&lt;p&gt;These terminal examples target &lt;strong&gt;macOS with zsh&lt;/strong&gt;, &lt;strong&gt;lego v5&lt;/strong&gt;, and OpenSSL 3. Check &lt;code&gt;lego --version&lt;/code&gt; and &lt;code&gt;openssl version&lt;/code&gt;; Homebrew OpenSSL may need its full path, &lt;code&gt;$(brew --prefix openssl@3)/bin/openssl&lt;/code&gt;. Existing lego v4 users should follow the &lt;a href="https://go-acme.github.io/lego/migration/cli/" rel="noopener noreferrer"&gt;v5 migration guide&lt;/a&gt;. Linux users need their distribution's CA-bundle path and clipboard command; &lt;code&gt;pbcopy&lt;/code&gt; and &lt;code&gt;base64 -i&lt;/code&gt; are macOS-specific.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A domain on Cloudflare&lt;/strong&gt; (or any DNS provider &lt;code&gt;lego&lt;/code&gt; supports). Pick a plain name like &lt;code&gt;db.example.com&lt;/code&gt;. There's no point making it random: every Let's Encrypt name lands in public Certificate Transparency logs anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scoped Cloudflare API token&lt;/strong&gt;, limited to that one zone, with two permissions:

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Zone → DNS → Edit&lt;/em&gt;, to write the challenge TXT record;&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Zone → Zone → Read&lt;/em&gt;, so &lt;code&gt;lego&lt;/code&gt; can find the zone. Without it, issuance fails.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;lego&lt;/code&gt;&lt;/strong&gt; and a modern &lt;strong&gt;OpenSSL&lt;/strong&gt; (for &lt;code&gt;-starttls postgres&lt;/code&gt;):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  brew &lt;span class="nb"&gt;install &lt;/span&gt;lego openssl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A backup.&lt;/strong&gt; On plans without a Backups tab, take one from the service console and keep it until you've restored a copy somewhere else:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Railway console (Postgres service)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; railway &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/lib/postgresql/pre-tls.dump
  &lt;span class="nb"&gt;chmod &lt;/span&gt;600 /var/lib/postgresql/pre-tls.dump
  pg_restore &lt;span class="nt"&gt;--list&lt;/span&gt; /var/lib/postgresql/pre-tls.dump | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pg_restore --list&lt;/code&gt; only reads the catalog. It doesn't prove the dump restores, and a dump on the database's own volume won't survive losing that volume.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No &lt;code&gt;psql&lt;/code&gt; on your laptop?&lt;/strong&gt; The runbook uses the official Docker image for the public probes. Mount an explicit CA bundle so verification is independent of what that image ships: &lt;code&gt;-v /etc/ssl/cert.pem:/certs/roots.pem:ro&lt;/code&gt; on macOS.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 0: Record the Current State
&lt;/h2&gt;

&lt;p&gt;Two things to write down before changing anything. First, the service's &lt;strong&gt;start command&lt;/strong&gt; from Settings → Deploy. On the pgvector template it looks like this, and it's your rollback value:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original start command&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"unset PGPORT; docker-entrypoint.sh postgres --port=5432"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the &lt;strong&gt;current &lt;code&gt;pg_hba.conf&lt;/code&gt; rules&lt;/strong&gt;, from the service console:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Railway console&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'^\s*(#|$)'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-Atc&lt;/span&gt; &lt;span class="s1"&gt;'show hba_file'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image default is &lt;code&gt;trust&lt;/code&gt; for local socket and loopback connections, the matching replication lines, and &lt;code&gt;host all all all scram-sha-256&lt;/code&gt;. Any extra line you find must carry over into step 4.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Issue the Certificate
&lt;/h2&gt;

&lt;p&gt;Run this in a normal terminal, not inside an AI assistant or anything else that logs, so the token never lands in a transcript. &lt;code&gt;read -s&lt;/code&gt; keeps it out of your shell history too:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Issue (zsh)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'CLOUDFLARE_DNS_API_TOKEN?Cloudflare token: '&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;CLOUDFLARE_DNS_API_TOKEN
lego run &lt;span class="nt"&gt;--accept-tos&lt;/span&gt; &lt;span class="nt"&gt;--email&lt;/span&gt; you@example.com &lt;span class="nt"&gt;--dns&lt;/span&gt; cloudflare &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dns&lt;/span&gt;.resolvers &amp;lt;ns1&amp;gt;.ns.cloudflare.com:53,&amp;lt;ns2&amp;gt;.ns.cloudflare.com:53 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--domains&lt;/span&gt; db.example.com &lt;span class="nt"&gt;--key-type&lt;/span&gt; EC256 &lt;span class="nt"&gt;--path&lt;/span&gt; ~/.lego-db
&lt;span class="nb"&gt;unset &lt;/span&gt;CLOUDFLARE_DNS_API_TOKEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In bash, use &lt;code&gt;read -rsp 'Cloudflare token: ' CLOUDFLARE_DNS_API_TOKEN&lt;/code&gt; instead. Replace the &lt;code&gt;&amp;lt;ns1&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;ns2&amp;gt;&lt;/code&gt; names with your zone's two assigned nameservers, from Cloudflare's DNS page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl694hq0wfm0mz7hzpcpd.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl694hq0wfm0mz7hzpcpd.png" alt="lego requests a certificate, creates a Cloudflare DNS TXT challenge, waits for validation by Let’s Encrypt, and removes the TXT record." width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;9109: Cannot use the access token from location&lt;/code&gt;.&lt;/strong&gt; The token's IP filter didn't match the address my terminal actually used. Command-line tools often go out over native IPv6 while the browser shows something else (iCloud Private Relay, a VPN). Check with &lt;code&gt;curl -s -6 https://api.cloudflare.com/cdn-cgi/trace | grep ip=&lt;/code&gt; (and &lt;code&gt;-4&lt;/code&gt;), and allow the IPv6 address as a &lt;code&gt;/64&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NXDOMAIN&lt;/code&gt; until the 2-minute timeout.&lt;/strong&gt; My router's resolver had cached "no such record" for the zone's negative-cache TTL, 30 minutes. &lt;code&gt;--dns.resolvers&lt;/code&gt; makes lego poll Cloudflare's authoritative nameservers, which don't cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then check what you got. The certificate is public, so this is safe to print. Never print the &lt;code&gt;.key&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inspect the certificate&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl x509 &lt;span class="nt"&gt;-in&lt;/span&gt; ~/.lego-db/certificates/db.example.com.crt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-subject&lt;/span&gt; &lt;span class="nt"&gt;-issuer&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt; &lt;span class="nt"&gt;-ext&lt;/span&gt; subjectAltName
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect the SAN to contain &lt;code&gt;db.example.com&lt;/code&gt;, a Let's Encrypt issuer, and a future end date. lego v5 omits the subject Common Name by default; an empty subject is not a failure. The &lt;code&gt;.crt&lt;/code&gt; holds the full chain (leaf plus intermediates), which is what Postgres should serve. See &lt;a href="https://go-acme.github.io/lego/obtain/" rel="noopener noreferrer"&gt;lego's certificate output&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Store It in Railway
&lt;/h2&gt;

&lt;p&gt;Railway variables are single strings, and PEM files are multi-line. Base64 them so the newlines survive, straight to the clipboard:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Copy cert → PG_SSL_CERT&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.lego-db/certificates/db.example.com.crt | pbcopy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Copy key → PG_SSL_KEY&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.lego-db/certificates/db.example.com.key | pbcopy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste each into a variable on the Postgres service. Adding variables stages a redeploy, so &lt;strong&gt;don't deploy yet&lt;/strong&gt;. Step 3's start command needs to be in place first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Offer TLS
&lt;/h2&gt;

&lt;p&gt;Replace the start command with this. It has to be one line:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start command: TLS offered&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"unset PGPORT; set -e; (umask 077; mkdir -p /tmp/ssl; printf %s &lt;/span&gt;&lt;span class="nv"&gt;$PG_SSL_CERT&lt;/span&gt;&lt;span class="s2"&gt; | base64 -d &amp;gt; /tmp/ssl/server.crt; printf %s &lt;/span&gt;&lt;span class="nv"&gt;$PG_SSL_KEY&lt;/span&gt;&lt;span class="s2"&gt; | base64 -d &amp;gt; /tmp/ssl/server.key); chown -R postgres:postgres /tmp/ssl; exec docker-entrypoint.sh postgres --port=5432 -c ssl=on -c ssl_cert_file=/tmp/ssl/server.crt -c ssl_key_file=/tmp/ssl/server.key -c ssl_min_protocol_version=TLSv1.3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Taken apart:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;(umask 077; …)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Writes owner-only files. The parentheses isolate the umask: in my image, a global umask carried into the entrypoint and caused Permission denied.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;printf %s $PG_SSL_CERT | base64 -d&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Decodes the variables. They are unquoted to avoid nesting quotes in the field; this relies on each variable containing only single-line base64, without whitespace or shell metacharacters.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chown -R postgres:postgres&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Makes the files readable by Postgres while the key remains owner-only.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;exec docker-entrypoint.sh postgres …&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Starts the custom image's normal entrypoint with TLS settings. The certificate files stay outside the data directory.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ssl_min_protocol_version=TLSv1.3&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Requires TLS 1.3. Check all client runtimes support it before deploying; PostgreSQL's default minimum is TLS 1.2.&lt;/p&gt;

&lt;p&gt;Deploy. Expect a short interruption; most connection pools retry, but check your application's reconnect behavior. Then check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The logs say &lt;code&gt;database system is ready to accept connections&lt;/code&gt;, with no &lt;code&gt;SSL&lt;/code&gt; or &lt;code&gt;private key&lt;/code&gt; errors.&lt;/li&gt;
&lt;li&gt;Your app still works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure where your app connects from.&lt;/strong&gt; This is the input to step 4:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Railway console&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-Atc&lt;/span&gt; &lt;span class="s2"&gt;"select distinct client_addr, s.ssl from pg_stat_activity a
  left join pg_stat_ssl s using (pid) where backend_type = 'client backend' and client_addr is not null"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My app connected over Railway's private IPv6 network from an address like &lt;code&gt;fd12:3456:789a:1:…&lt;/code&gt;, with &lt;code&gt;ssl = f&lt;/code&gt;. The example &lt;code&gt;/48&lt;/code&gt; is based on that setup. Railway &lt;a href="https://docs.railway.com/networking/private-networking" rel="noopener noreferrer"&gt;documents an isolated, encrypted private network&lt;/a&gt;, but does not promise that prefix length.&lt;/p&gt;

&lt;p&gt;Check every app, worker, and cron service, including fresh connections after redeploys. Compare their IPv4 and IPv6 source addresses with your environment's interface, route, and allocation information before choosing a range. One address alone cannot tell you its subnet. A &lt;code&gt;/128&lt;/code&gt; allows just one IPv6 address, but it may change on redeploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you cannot establish a suitable private range, require TLS everywhere.&lt;/strong&gt; In step 4, omit the entire &lt;code&gt;echo 'hostnossl …' &amp;gt;&amp;gt; /tmp/ssl/pg_hba.conf;&lt;/code&gt; statement. Before enforcing that variant, enable and test TLS in every private client during this offer phase. A certificate for &lt;code&gt;db.example.com&lt;/code&gt; will not pass hostname verification for &lt;code&gt;postgres.railway.internal&lt;/code&gt;; verified private clients need an appropriate certificate name and trusted CA, or must use the public verified endpoint. Do not solve that mismatch by disabling certificate validation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Require TLS Outside the Private Network
&lt;/h2&gt;

&lt;p&gt;Now the start command also writes a &lt;code&gt;pg_hba.conf&lt;/code&gt; into &lt;code&gt;/tmp/ssl&lt;/code&gt; and points Postgres at it with &lt;code&gt;hba_file&lt;/code&gt;. The rules are checked top to bottom, and the first match wins:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fieazxraduf9a1n1z0axa.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fieazxraduf9a1n1z0axa.png" alt="Connections are checked in order: trust local connections, authenticate approved private plaintext connections, authenticate TLS connections, and reject all other connections." width="800" height="1289"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start command: TLS required&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"unset PGPORT; set -e; (umask 077; mkdir -p /tmp/ssl; printf %s &lt;/span&gt;&lt;span class="nv"&gt;$PG_SSL_CERT&lt;/span&gt;&lt;span class="s2"&gt; | base64 -d &amp;gt; /tmp/ssl/server.crt; printf %s &lt;/span&gt;&lt;span class="nv"&gt;$PG_SSL_KEY&lt;/span&gt;&lt;span class="s2"&gt; | base64 -d &amp;gt; /tmp/ssl/server.key; echo 'local all all trust' &amp;gt; /tmp/ssl/pg_hba.conf; echo 'host all all 127.0.0.1/32 trust' &amp;gt;&amp;gt; /tmp/ssl/pg_hba.conf; echo 'host all all ::1/128 trust' &amp;gt;&amp;gt; /tmp/ssl/pg_hba.conf; echo 'hostnossl all all fd12:3456:789a::/48 scram-sha-256' &amp;gt;&amp;gt; /tmp/ssl/pg_hba.conf; echo 'hostssl all all all scram-sha-256' &amp;gt;&amp;gt; /tmp/ssl/pg_hba.conf); chown -R postgres:postgres /tmp/ssl; exec docker-entrypoint.sh postgres --port=5432 -c ssl=on -c ssl_cert_file=/tmp/ssl/server.crt -c ssl_key_file=/tmp/ssl/server.key -c ssl_min_protocol_version=TLSv1.3 -c hba_file=/tmp/ssl/pg_hba.conf"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;fd12:3456:789a::/48&lt;/code&gt; only with a private range you established in step 3, or omit that statement for the TLS-everywhere variant. Never copy the example range unchanged. The replication lines are gone because my service had no replicas; preserve any replication and custom rules your service needs. Those rules must not reintroduce a public plaintext path. PostgreSQL documents the &lt;a href="https://www.postgresql.org/docs/current/auth-pg-hba-conf.html" rel="noopener noreferrer"&gt;first-match rule and connection types&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Avoid backslashes in this Railway start command&lt;/p&gt;

&lt;p&gt;My first attempt wrote the rules with a single &lt;code&gt;printf '%s\n' …&lt;/code&gt;. Railway's field strips backslashes, so &lt;code&gt;\n&lt;/code&gt; became &lt;code&gt;n&lt;/code&gt;, the five rules ran together into one line, and Postgres refused to start: &lt;code&gt;invalid authentication method "trustnhost"&lt;/code&gt;. That's why each rule now gets its own &lt;code&gt;echo&lt;/code&gt;. If a deploy fails, put the step 3 command back; it's known to work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Test it locally first.&lt;/strong&gt; Use a disposable database with the same image tag/digest and an empty volume. Strip backslashes to check for the field behavior that caused my outage. Confirm that &lt;code&gt;pg_hba_file_rules&lt;/code&gt; shows the intended rules without errors. From outside the allowed private range, check that plaintext is refused and verified TLS succeeds. The TLS-everywhere variant has four rules rather than five.&lt;/p&gt;

&lt;p&gt;Deploy, then check in the console:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Railway console&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-Atc&lt;/span&gt; &lt;span class="s2"&gt;"show hba_file"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"select line_number, type, address, netmask, error from pg_hba_file_rules"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect &lt;code&gt;/tmp/ssl/pg_hba.conf&lt;/code&gt; and an empty &lt;code&gt;error&lt;/code&gt; column for every rule. Open fresh app connections and rerun step 3's query to check their source address and TLS state; a cached successful request does not prove a rule matches. Watch each cron job's next run too. A &lt;code&gt;no pg_hba.conf entry&lt;/code&gt; error names the rejected address: investigate its origin before changing the policy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Open the Proxy and Point DNS at It
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Postgres service → Settings → Networking → &lt;strong&gt;TCP Proxy&lt;/strong&gt;, target port &lt;strong&gt;5432&lt;/strong&gt;. Railway assigns &lt;code&gt;xxxx.proxy.rlwy.net:NNNNN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In Cloudflare DNS, add &lt;code&gt;CNAME db → xxxx.proxy.rlwy.net&lt;/code&gt;, set to &lt;strong&gt;DNS only (grey cloud)&lt;/strong&gt;. The orange-cloud proxy only carries HTTP.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Connect with the hostname and the proxy's &lt;strong&gt;port&lt;/strong&gt;: &lt;code&gt;db.example.com:NNNNN&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Verify, Without Sending a Password Until the End
&lt;/h2&gt;

&lt;p&gt;Set two shell variables, then run the checks in order. The first two never send a credential.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;H&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;db.example.com
&lt;span class="nv"&gt;P&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NNNNN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;a. The chain and hostname verify, strictly:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6a: TLS handshake&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; pipefail
  openssl s_client &lt;span class="nt"&gt;-brief&lt;/span&gt; &lt;span class="nt"&gt;-starttls&lt;/span&gt; postgres &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="nv"&gt;$H&lt;/span&gt;:&lt;span class="nv"&gt;$P&lt;/span&gt; &lt;span class="nt"&gt;-servername&lt;/span&gt; &lt;span class="nv"&gt;$H&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-verify_hostname&lt;/span&gt; &lt;span class="nv"&gt;$H&lt;/span&gt; &lt;span class="nt"&gt;-verify_return_error&lt;/span&gt; &lt;span class="nt"&gt;-CAfile&lt;/span&gt; /etc/ssl/cert.pem &amp;lt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'Protocol version|Verification|Peer certificate|Verified peername'&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want &lt;code&gt;Verification: OK&lt;/code&gt;, &lt;code&gt;Protocol version: TLSv1.3&lt;/code&gt;, and a successful command exit status. The pipefail setting keeps a failed OpenSSL check from being masked by a successful grep. Any verify error is a stop: remove the proxy and investigate. See Railway's &lt;a href="https://docs.railway.com/networking/tcp-proxy" rel="noopener noreferrer"&gt;TCP proxy and custom-domain documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;b. Plaintext is refused.&lt;/strong&gt; &lt;code&gt;-w&lt;/code&gt; means psql never prompts for a password:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6b: plaintext must fail&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; postgres:18 psql &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"host=&lt;/span&gt;&lt;span class="nv"&gt;$H&lt;/span&gt;&lt;span class="s2"&gt; port=&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt; dbname=railway user=postgres sslmode=disable"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select 1'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want &lt;code&gt;FATAL: no pg_hba.conf entry for host "…", … no encryption&lt;/code&gt;. Note the source address in that message and check that it falls outside your private plaintext exception. In my setup it was in &lt;code&gt;100.64.0.0/10&lt;/code&gt;. The plaintext connection matches no permitted rule and is refused; the &lt;code&gt;hostssl&lt;/code&gt; rule can match only an encrypted connection.&lt;/p&gt;

&lt;p&gt;If you get &lt;code&gt;fe_sendauth: no password supplied&lt;/code&gt; instead, plaintext was &lt;strong&gt;accepted&lt;/strong&gt;: proxied connections are landing inside your &lt;code&gt;hostnossl&lt;/code&gt; range. Remove the proxy and rethink. The fallback is to require TLS on the private network too.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Side effect: since every proxied connection comes from the proxy's address, Postgres never sees the real client. Its logs can't tell you who tried to connect.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;c. A real login, fully verified.&lt;/strong&gt; Type the password at the prompt:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6c: verify-full login&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; /etc/ssl/cert.pem:/certs/roots.pem:ro postgres:18 psql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"host=&lt;/span&gt;&lt;span class="nv"&gt;$H&lt;/span&gt;&lt;span class="s2"&gt; port=&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt; dbname=railway user=postgres sslmode=verify-full sslrootcert=/certs/roots.pem"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"select ssl, version from pg_stat_ssl where pid = pg_backend_pid();"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected&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; t | TLSv1.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;verify-full&lt;/code&gt; checks the chain &lt;strong&gt;and&lt;/strong&gt; the hostname before authenticating. If anything is wrong, the client stops before the password exchange starts: it fails closed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Using It From Real Clients
&lt;/h2&gt;

&lt;p&gt;Every client gets the same rule: &lt;code&gt;sslmode=verify-full&lt;/code&gt;, nothing weaker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection URL&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;postgresql://postgres:&amp;lt;password&amp;gt;@db.example.com:NNNNN/railway?sslmode=verify-full
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Percent-encode the password&lt;/strong&gt; if it contains &lt;code&gt;@ / : # ? %&lt;/code&gt;. Otherwise the URL parses wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node &lt;code&gt;postgres.js&lt;/code&gt;&lt;/strong&gt; reads &lt;code&gt;sslmode=verify-full&lt;/code&gt; from the URL and checks against Node's built-in roots. No extra config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;psql in Docker&lt;/strong&gt; needs &lt;code&gt;&amp;amp;sslrootcert=/certs/roots.pem&lt;/code&gt; plus the mount from step 6c.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DataGrip / JetBrains:&lt;/strong&gt; enable SSL, select &lt;em&gt;Full Verification&lt;/em&gt;, and explicitly enable &lt;em&gt;Use truststore&lt;/em&gt; with System, Java, or IDE roots that trust the issuer. No client certificate is needed. Use the same &lt;code&gt;db.example.com&lt;/code&gt; hostname as the certificate. See &lt;a href="https://www.jetbrains.com/help/datagrip/configuring-ssh-and-ssl.html" rel="noopener noreferrer"&gt;JetBrains' SSL instructions&lt;/a&gt;. Consider marking the connection read-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Better still, make your scripts &lt;strong&gt;refuse&lt;/strong&gt; a remote URL that doesn't say &lt;code&gt;sslmode=verify-full&lt;/code&gt;. &lt;code&gt;sslmode=require&lt;/code&gt; encrypts but doesn't check who's on the other end, so one missing parameter quietly downgrades you to "encrypted to anyone". A guard catches that before it happens:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;connection guard&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;remoteTlsRefusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connectionString&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgresql:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Expected a PostgreSQL connection URL&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;host&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hostaddr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pass the destination in the URL authority, without host overrides&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;::1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[::1]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&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="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sslmode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;modes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;modes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;verify-full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Refusing &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: remote connections need sslmode=verify-full`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This guard is a configuration check, not a substitute for the driver's TLS verification. Use the same validated URL when connecting and do not override its SSL settings afterward. Invalid URLs throw and should stop the script. Keep hostname-mismatch and untrusted-certificate tests in scripts that depend on this guarantee.&lt;/p&gt;

&lt;p&gt;To read the password without it touching your shell history, percent-encode it before building the URL. &lt;code&gt;encodeURIComponent&lt;/code&gt; handles the reserved characters listed above; passing it through the environment keeps the value out of the Node command's arguments:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-off session (zsh)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'PGPW?postgres password: '&lt;/span&gt;
&lt;span class="nv"&gt;PGPW_ENCODED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;PGPW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PGPW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; node &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'encodeURIComponent(process.env.PGPW)'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgresql://postgres:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PGPW_ENCODED&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@db.example.com:NNNNN/railway?sslmode=verify-full"&lt;/span&gt;
&lt;span class="c"&gt;# ... run your script ...&lt;/span&gt;
&lt;span class="nb"&gt;unset &lt;/span&gt;PGPW PGPW_ENCODED DATABASE_URL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Renewal: Follow the Certificate's Lifetime
&lt;/h2&gt;

&lt;p&gt;As of this post, Let's Encrypt's default certificates last 90 days. The &lt;a href="https://letsencrypt.org/upcoming-features/" rel="noopener noreferrer"&gt;announced schedule&lt;/a&gt; reduces that to 64 days on February 10, 2027 and 45 days on February 16, 2028. Do not hard-code a 60-day renewal interval.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23gdoojfp74lcg1ssu6v.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23gdoojfp74lcg1ssu6v.png" alt="Check certificate expiry and the token IP filter, run lego, update both Railway variables, redeploy, and repeat the verification checks." width="552" height="1286"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Record &lt;code&gt;NotBefore&lt;/code&gt; and &lt;code&gt;NotAfter&lt;/code&gt;. Schedule renewal around two-thirds through the actual lifetime, with repeat reminders until the deployed certificate is confirmed updated. Monitor the served endpoint's expiry independently of the local certificate.&lt;/li&gt;
&lt;li&gt;Check your terminal's IP still passes the token's filter (&lt;code&gt;curl -s -6 https://api.cloudflare.com/cdn-cgi/trace | grep ip=&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Run step 1's three lines again with lego v5. It supports ACME Renewal Information (ARI) and a dynamic renewal threshold; see &lt;a href="https://go-acme.github.io/lego/references/ref-flags/" rel="noopener noreferrer"&gt;its renewal flags&lt;/a&gt;. A successful no-op does not mean a new certificate was issued: check the local expiry. If your manual deployment window requires an early renewal, use &lt;code&gt;--renew-force&lt;/code&gt; deliberately.&lt;/li&gt;
&lt;li&gt;Update both variables (step 2) and redeploy.&lt;/li&gt;
&lt;li&gt;Re-run all of step 6, and confirm the new &lt;code&gt;NotAfter&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you miss a renewal, Postgres keeps serving the expired certificate and verified clients refuse to connect. Private clients using the plaintext exception are unaffected; verified private clients fail too. Encryption does not silently downgrade, but the resulting outage can still be serious.&lt;/p&gt;

&lt;p&gt;Automating this means a scheduled job holding both a Railway token and a DNS token. That's worth its own security review before you build it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;

&lt;p&gt;Nothing above touches the data or the volume's own &lt;code&gt;pg_hba.conf&lt;/code&gt;, so rolling back only undoes configuration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remove the TCP proxy, which closes public access immediately.&lt;/li&gt;
&lt;li&gt;Restore the step 0 start command and delete &lt;code&gt;PG_SSL_CERT&lt;/code&gt; / &lt;code&gt;PG_SSL_KEY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Redeploy Postgres and check the app.&lt;/li&gt;
&lt;li&gt;Delete the CNAME.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A TCP proxy is not a security boundary.&lt;/strong&gt; If the protocol behind it doesn't encrypt, neither does the proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ssl=on&lt;/code&gt; offers; &lt;code&gt;pg_hba&lt;/code&gt; requires.&lt;/strong&gt; Until a &lt;code&gt;hostssl&lt;/code&gt; rule is the only way in from outside, a client can still pick plaintext.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure, then restrict.&lt;/strong&gt; Observed addresses give you evidence for a private exception, not proof of a permanent subnet. Check all clients across redeploys; require TLS everywhere if you cannot establish a suitable range.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make every check fail closed.&lt;/strong&gt; &lt;code&gt;verify-full&lt;/code&gt; on the client, a refused plaintext probe, and scripts that won't run without the right &lt;code&gt;sslmode&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect the field you're typing into.&lt;/strong&gt; A start-command box isn't a shell. One stripped backslash took the database down for two minutes.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>railway</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Built a Full-Stack F1 Fantasy Platform in 4 Weeks — Solo, With AI Agents</title>
      <dc:creator>Rakesh Paul</dc:creator>
      <pubDate>Sun, 08 Feb 2026 02:27:00 +0000</pubDate>
      <link>https://dev.to/rakesh_paul/i-built-a-full-stack-f1-fantasy-platform-in-4-weeks-solo-with-ai-agents-3195</link>
      <guid>https://dev.to/rakesh_paul/i-built-a-full-stack-f1-fantasy-platform-in-4-weeks-solo-with-ai-agents-3195</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%2Ffwe10lymgb16c7jjbeip.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%2Ffwe10lymgb16c7jjbeip.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;This post was originally published on &lt;a href="https://binaryroute.com/blog/building-formula1-plus-solo-with-ai-agents" rel="noopener noreferrer"&gt;binaryroute.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In late December 2025, during a short holiday break, I opened my laptop with a simple idea — build the F1 fantasy league platform I'd always wanted as a fan.&lt;/p&gt;

&lt;p&gt;Four weeks later, &lt;a href="https://formula1.plus" rel="noopener noreferrer"&gt;Formula1.Plus&lt;/a&gt; is live. 70+ database tables. 30+ API modules. Race predictions, live leaderboards, private leagues, telemetry dashboards, news aggregation, community features, and a full admin panel.&lt;/p&gt;

&lt;p&gt;Built and shipped by one developer.&lt;/p&gt;

&lt;p&gt;This post is a technical walkthrough of how I pulled it off — the framework I built first, the stack I chose, the AI workflow that made it possible, and what I'd do differently.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/your-screenshot-url" 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/your-screenshot-url" alt="Formula1.Plus Dashboard" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The Problem With Building Solo
&lt;/h2&gt;

&lt;p&gt;If you've tried building a full-stack product alone, you know the pain. You're the architect, the frontend dev, the backend dev, the DBA, the DevOps person, and the QA — all at once.&lt;/p&gt;

&lt;p&gt;Every context switch costs you. You spend more time on plumbing than on the product. Projects stall, scope shrinks, or you burn out halfway through.&lt;/p&gt;

&lt;p&gt;A project with this scope — predictions engine, scoring system, leaderboards, leagues, news aggregation with semantic search, telemetry dashboards, background job processing, passkey auth — would have been a 6-month grind. Probably abandoned by month 3.&lt;/p&gt;

&lt;p&gt;Two things changed the math: &lt;strong&gt;a framework I built to eliminate boilerplate&lt;/strong&gt; and &lt;strong&gt;AI agents as pair programmers&lt;/strong&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Build the Framework First
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of F1 code, I built &lt;strong&gt;ProjectX&lt;/strong&gt; — an opinionated full-stack TypeScript framework with a CLI.&lt;/p&gt;

&lt;p&gt;The idea: define your database schema once, run one command, and get everything generated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;projectx crud &lt;span class="nt"&gt;--models&lt;/span&gt; drivers,races,predictions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single command generates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API routes&lt;/strong&gt; with Hono, including input validation via Zod&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service layer&lt;/strong&gt; with business logic and authorization hooks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repository layer&lt;/strong&gt; with Drizzle ORM queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Query hooks&lt;/strong&gt; for the frontend with proper cache keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit test scaffolds&lt;/strong&gt; for the service layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All type-safe. All wired up with dependency injection. No manual API type definitions — the frontend imports route types from the backend via Hono's typed client.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP Layer (Hono Routes)
    ↓
Middleware (Auth, Rate Limiting, Logging, Caching)
    ↓
DI Container
    ↓
Service Layer (Business Logic + Authorization)
    ↓
Repository Layer (Drizzle ORM)
    ↓
PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every feature follows this pattern. Every feature gets its own folder. The AI agents I used later could navigate this structure instantly because it was consistent everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Monorepo
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;f1plus/
├── apps/
│   ├── api/          # Hono backend
│   └── web/          # TanStack Start frontend
├── packages/
│   ├── db/           # Drizzle schemas + migrations
│   ├── db-sync/      # F1 data synchronization
│   ├── types/        # Shared TypeScript types
│   ├── ui/           # shadcn/ui component library
│   ├── emails/       # React Email templates
│   ├── env/          # Zod-based env validation
│   └── tsconfig/     # Shared TS configs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven shared packages. Two apps. One &lt;code&gt;pnpm&lt;/code&gt; workspace. Everything shares types, nothing drifts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This structure was the single most important decision.&lt;/strong&gt; Not because it's novel — because it gave me and the AI agents a predictable codebase from day one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: The Stack
&lt;/h2&gt;

&lt;p&gt;I chose each piece deliberately. Here's what and why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend: TanStack Start + React 19
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://tanstack.com/start" rel="noopener noreferrer"&gt;TanStack Start&lt;/a&gt; is still relatively new, but it clicked for this project. SSR-ready, file-based routing via TanStack Router, and TanStack Query baked in for data fetching.&lt;/p&gt;

&lt;p&gt;The routing is fully type-safe — route params, search params, loaders — all typed. Combined with Hono's typed client on the backend, I get end-to-end type safety from the database to the component without writing a single manual type.&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;// Frontend hook — generated by ProjectX CLI&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useDriverStandings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seasonId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;driver-standings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seasonId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;standings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drivers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$get&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;seasonId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query function is typed from the Hono route definition. Change the API response shape and TypeScript catches it in the frontend immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Styling: Tailwind CSS v4 + shadcn/ui
&lt;/h3&gt;

&lt;p&gt;Tailwind v4 with CSS custom properties for theming. I defined a set of design tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;--f1-bg-card&lt;/span&gt;
&lt;span class="nt"&gt;--f1-bg-secondary&lt;/span&gt;
&lt;span class="nt"&gt;--f1-border&lt;/span&gt;
&lt;span class="nt"&gt;--f1-text&lt;/span&gt;
&lt;span class="nt"&gt;--f1-text-muted&lt;/span&gt;
&lt;span class="nt"&gt;--f1-red&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These resolve to different values in light and dark mode. Every component uses these tokens instead of hardcoded colors. This made the entire UI theme-able with zero per-component overrides.&lt;/p&gt;

&lt;p&gt;shadcn/ui gives you accessible, unstyled primitives that you own. No dependency lock-in. I customized every component to match the F1 aesthetic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend: Hono
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://hono.dev" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; is a 15KB web framework that runs everywhere — Node, Cloudflare Workers, Deno, Bun. I chose it for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Typed routes&lt;/strong&gt; — The &lt;code&gt;hono/client&lt;/code&gt; package gives you zero-runtime-overhead type inference. The frontend knows every route's request/response shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware composition&lt;/strong&gt; — Rate limiting, auth, logging, body limits, CORS — all composable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt; — It's fast. Measurably fast.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Rate limiting tiers&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rateLimits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;expensive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1m&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;h3&gt;
  
  
  Database: Drizzle ORM + PostgreSQL + pgvector
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://orm.drizzle.team" rel="noopener noreferrer"&gt;Drizzle ORM&lt;/a&gt; is the sweet spot between raw SQL and heavy ORMs. Type-safe queries, zero runtime overhead, and the schema definitions are plain TypeScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;drivers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drivers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;abbreviation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;abbreviation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;nationality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nationality&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;dateOfBirth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;date_of_birth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added &lt;code&gt;pgvector&lt;/code&gt; for semantic search on news articles — embedding articles with HuggingFace Transformers and querying by similarity. This lets users search F1 news by meaning, not just keywords.&lt;/p&gt;

&lt;h3&gt;
  
  
  Background Jobs: BullMQ + Redis
&lt;/h3&gt;

&lt;p&gt;Five job workers handle async processing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scoring worker&lt;/strong&gt; — calculates race scores and updates leaderboards&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;News sync&lt;/strong&gt; — fetches and processes F1 news articles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email worker&lt;/strong&gt; — transactional emails via &lt;a href="https://react.email" rel="noopener noreferrer"&gt;React Email&lt;/a&gt; + &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F1DB sync&lt;/strong&gt; — auto-syncs historical data from &lt;a href="https://github.com/f1db/f1db" rel="noopener noreferrer"&gt;F1DB&lt;/a&gt;, the community-maintained open-source F1 dataset on GitHub&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Task worker&lt;/strong&gt; — generic async tasks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bull Board provides an admin dashboard for monitoring all queues.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auth: BetterAuth + Passkeys
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://better-auth.com" rel="noopener noreferrer"&gt;BetterAuth&lt;/a&gt; handles OAuth (Google, Discord, X) and passkey/WebAuthn support. Passkeys are the future of auth — no passwords, phishing-resistant, biometric. It took one afternoon to set up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: AI Agents as the Development Team
&lt;/h2&gt;

&lt;p&gt;This is the part that made 4 weeks possible.&lt;/p&gt;

&lt;p&gt;I used &lt;strong&gt;Claude Code&lt;/strong&gt;, &lt;strong&gt;OpenAI Codex&lt;/strong&gt;, and &lt;strong&gt;Gemini&lt;/strong&gt; throughout the entire build — not as autocomplete, but as collaborators that could hold the full codebase in context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt; was the core developer — writing, refactoring, and debugging code directly in the codebase. &lt;strong&gt;Claude, Codex, and Gemini&lt;/strong&gt; served as architects: every non-trivial feature went through an extensive design process where I'd gather perspectives from multiple models before settling on a final implementation plan. Different models catch different edge cases, and the overlap builds confidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  What AI agents actually did
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Feature scaffolding.&lt;/strong&gt; I'd describe a feature ("add a predictions system where users pick drivers for each race, with a lock-of-the-week mechanic for bonus points"), and the agent would generate the schema, the service, the routes, the validation, and the frontend hooks. Not perfect on the first pass, but 80% there. I'd review, adjust, and iterate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel code reviews.&lt;/strong&gt; When I needed to migrate 50+ component files from hardcoded &lt;code&gt;bg-white/10&lt;/code&gt; opacity patterns to CSS custom properties for light mode support, I launched three AI agents in parallel — each handling a batch of files, following the same mapping guide. What would have been a full day of tedious find-and-replace was done in minutes with consistent results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging.&lt;/strong&gt; One example: the track circuit SVG component had a blur effect from 8 stacked CSS &lt;code&gt;drop-shadow&lt;/code&gt; filters that were compounding exponentially. The agent identified the root cause (each filter applies to the cumulative result of all previous filters), removed the outline system, and adjusted stroke widths — a fix I might have spent an hour on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistency.&lt;/strong&gt; As the codebase grew past 50+ components, the AI kept patterns consistent — same naming conventions, same file structure, same validation approach. This is where solo developers usually start cutting corners.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why structure matters for AI
&lt;/h3&gt;

&lt;p&gt;Here's the key insight: &lt;strong&gt;AI agents are only as good as the patterns you give them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ProjectX's opinionated architecture meant the AI always had guardrails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It knew services go in &lt;code&gt;features/&amp;lt;name&amp;gt;/&amp;lt;name&amp;gt;.service.ts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;It knew validation uses Zod schemas in &lt;code&gt;features/&amp;lt;name&amp;gt;/validations.ts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;It knew routes are thin — they delegate to services&lt;/li&gt;
&lt;li&gt;It knew the frontend hooks follow TanStack Query conventions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without that consistency, you're just generating spaghetti faster. The framework wasn't just for me — it was for the AI too.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Deployment — Cheaper Than You Think
&lt;/h2&gt;

&lt;p&gt;The deployment story is one of my favorite parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend: Cloudflare Workers
&lt;/h3&gt;

&lt;p&gt;TanStack Start builds to Cloudflare Workers via the Vite plugin. The frontend runs at the edge, globally distributed, with near-zero cold starts.&lt;/p&gt;

&lt;p&gt;Cost: practically free at this scale. Cloudflare's free tier is generous.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend: Railway
&lt;/h3&gt;

&lt;p&gt;Railway runs the Hono API, PostgreSQL, and Redis. Docker multi-stage build keeps the image lean. Health checks, auto-restarts, and deploy previews are built in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# railway.toml&lt;/span&gt;
&lt;span class="nn"&gt;[deploy]&lt;/span&gt;
&lt;span class="py"&gt;healthcheckPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/health/ready"&lt;/span&gt;
&lt;span class="py"&gt;healthcheckTimeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
&lt;span class="py"&gt;restartPolicyType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"on_failure"&lt;/span&gt;
&lt;span class="py"&gt;restartPolicyMaxRetries&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CI/CD: GitHub Actions
&lt;/h3&gt;

&lt;p&gt;Two workflows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CI&lt;/strong&gt; — lint, type-check, build, test on every PR&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt; — manual trigger, runs migrations first, then deploys API to Railway and frontend to Cloudflare
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Push to main → CI passes → Trigger deploy →
  Run migrations → Deploy API (Railway) → Deploy Web (Cloudflare)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Total monthly cost
&lt;/h3&gt;

&lt;p&gt;The entire production infrastructure — API server, PostgreSQL, Redis, edge-deployed frontend, CI/CD — costs approximately &lt;strong&gt;\$20/month&lt;/strong&gt; — or closer to ~\$10/month with serverless database and Redis options. No Kubernetes. No Terraform. No DevOps engineer.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Inside Formula1.Plus
&lt;/h2&gt;

&lt;p&gt;Here's what shipped in 4 weeks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Predictions &amp;amp; Scoring&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Race predictions with driver and constructor picks&lt;/li&gt;
&lt;li&gt;Lock-of-the-week mechanic for bold predictions (bonus points)&lt;/li&gt;
&lt;li&gt;Last place picks, constructor top-3, bonus picks&lt;/li&gt;
&lt;li&gt;Automated scoring engine via background workers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Leaderboards &amp;amp; Leagues&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live leaderboards with all-time and per-championship rankings&lt;/li&gt;
&lt;li&gt;Private leagues with invite codes&lt;/li&gt;
&lt;li&gt;League-specific leaderboards and standings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Telemetry &amp;amp; Data&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Driver DNA breakdowns and performance analysis&lt;/li&gt;
&lt;li&gt;Historical data auto-synced from &lt;a href="https://github.com/f1db/f1db" rel="noopener noreferrer"&gt;F1DB&lt;/a&gt;, a community-maintained open-source dataset&lt;/li&gt;
&lt;li&gt;Circuit profiles with past results and statistics&lt;/li&gt;
&lt;li&gt;Recharts-powered visualizations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Community&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grand Stand — polls, discussions, community engagement&lt;/li&gt;
&lt;li&gt;News aggregation with semantic search (pgvector)&lt;/li&gt;
&lt;li&gt;Activity feeds and social features&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Admin&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Event management and prediction configuration&lt;/li&gt;
&lt;li&gt;Queue monitoring via Bull Board&lt;/li&gt;
&lt;li&gt;Audit logs, contact management, poll templates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Auth &amp;amp; Infrastructure&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OAuth (Google, Discord, X) + passkey/WebAuthn&lt;/li&gt;
&lt;li&gt;Rate limiting (tiered: global, mutations, expensive queries)&lt;/li&gt;
&lt;li&gt;OpenTelemetry for distributed tracing&lt;/li&gt;
&lt;li&gt;Structured logging with Pino&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/your-feature-screenshots" 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/your-feature-screenshots" alt="Feature Overview" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The F1 season is approaching, and I'm getting early users in before Round 1.&lt;/p&gt;

&lt;p&gt;More importantly: &lt;strong&gt;ProjectX — the framework that made this possible — is going open source soon.&lt;/strong&gt; A single monorepo CLI that scaffolds web, mobile, and browser extensions with the full architecture out of the box.&lt;/p&gt;

&lt;p&gt;The plugin system, the CRUD generator, the Railway and Cloudflare deployment presets — all of it.&lt;/p&gt;

&lt;p&gt;If you want to follow the open source drop, I'll be posting updates on &lt;a href="https://dev.toyour-x-url"&gt;X/Twitter&lt;/a&gt; and the repo will be at &lt;a href="https://dev.toyour-github-url"&gt;github.com/your-handle/projectx&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Built a full F1 fantasy platform in 4 weeks, solo&lt;/li&gt;
&lt;li&gt;Built a CLI framework first to eliminate boilerplate (ProjectX)&lt;/li&gt;
&lt;li&gt;Used AI agents as pair programmers, not just autocomplete&lt;/li&gt;
&lt;li&gt;Stack: TanStack Start + Hono + Drizzle + PostgreSQL + Redis&lt;/li&gt;
&lt;li&gt;Deployed to Cloudflare Workers + Railway for approximately \$20/month (~\$10 with serverless options)&lt;/li&gt;
&lt;li&gt;ProjectX going open source soon&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://formula1.plus" rel="noopener noreferrer"&gt;formula1.plus&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What questions do you have about the build, the stack, or AI-assisted development? I'll be in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>typescript</category>
      <category>react</category>
    </item>
  </channel>
</rss>
