<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: jsph</title>
    <description>The latest articles on Forem by jsph (@jsph).</description>
    <link>https://forem.com/jsph</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3848483%2F780cad6f-ff8f-47d7-964a-757b38fa279d.png</url>
      <title>Forem: jsph</title>
      <link>https://forem.com/jsph</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/jsph"/>
    <language>en</language>
    <item>
      <title>SEO and LLM Discoverability for Phoenix Web Apps</title>
      <dc:creator>jsph</dc:creator>
      <pubDate>Thu, 16 Apr 2026 02:56:45 +0000</pubDate>
      <link>https://forem.com/jsph/seo-and-llm-discoverability-for-phoenix-web-apps-10ak</link>
      <guid>https://forem.com/jsph/seo-and-llm-discoverability-for-phoenix-web-apps-10ak</guid>
      <description>&lt;h1&gt;
  
  
  How I set up SEO and LLM discoverability for my Phoenix app
&lt;/h1&gt;

&lt;p&gt;I recently shipped the discoverability layer for &lt;a href="https://wishlistpalace.com" rel="noopener noreferrer"&gt;Wish List Palace&lt;/a&gt;, a free wish list app for coordinating family gift giving. This post covers everything I put in place: standard search engine SEO, structured data, and the newer &lt;code&gt;llms.txt&lt;/code&gt; standard for getting picked up by AI tools and training crawlers. I hope people stumble upon this site. People who for whatever reason have a need for it, but I also definitely didn't want to buy ads or try to exploit SEO. The wishlist app space is already super crowded so I don't think any of those strategies would help much but making it easier for crawlers and LLMs to understand what the app is and what it does was the goal.&lt;/p&gt;

&lt;p&gt;The stack is &lt;a href="https://hexdocs.pm/phoenix_live_view/" rel="noopener noreferrer"&gt;Phoenix LiveView&lt;/a&gt;, but most of this applies to any web app. Also LiveView isn't really necessary for what follows since it's mostly about setting up certain static assets to serve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standard SEO foundations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Meta and Open Graph tags
&lt;/h3&gt;

&lt;p&gt;Every page gets a full set of tags in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt; — the snippet shown in Google results&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ogp.me/" rel="noopener noreferrer"&gt;Open Graph&lt;/a&gt; tags (&lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:type&lt;/code&gt;, &lt;code&gt;og:url&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;) — controls the preview card when a URL is shared on WhatsApp, iMessage, Slack, etc.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards" rel="noopener noreferrer"&gt;Twitter Card&lt;/a&gt; tags — same idea for Twitter/X&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; — prevents duplicate content issues when a CDN like Cloudflare serves cached variants at different URLs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Phoenix, the root layout sets sensible defaults. Individual LiveViews can override them in &lt;code&gt;mount/3&lt;/code&gt; via assigns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;page_title:&lt;/span&gt; &lt;span class="s2"&gt;"How It Works — Wish List Palace"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;page_description:&lt;/span&gt; &lt;span class="s2"&gt;"Step-by-step guide to creating lists, sharing, and claiming gifts."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;canonical_url:&lt;/span&gt; &lt;span class="s2"&gt;"https://wishlistpalace.com/how-it-works"&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: &lt;code&gt;og:image&lt;/code&gt; is commonly set to an SVG logo, but WhatsApp, Twitter/X, and iMessage silently ignore SVGs. You need a PNG or JPG (1200×630px is the recommended size) to get actual preview images in link cards.&lt;/p&gt;

&lt;h3&gt;
  
  
  robots.txt
&lt;/h3&gt;

&lt;p&gt;The classic way to let crawlers (who want to obey your suggestions, at least) know what's the deal with your web app is &lt;code&gt;yourdomain.com/robots.txt&lt;/code&gt;. The first thing to do is disallow authenticated and admin routes so crawlers don't waste crawl budget on pages they can't access anyway. Since wishlistpalace has users with accounts and you have to be authenticated to see your own lists, there is no need for robots to visit &lt;code&gt;/lists&lt;/code&gt; since they are not authenticated users.&lt;/p&gt;

&lt;p&gt;Explicitly allow the public pages and LLM files. Basically whatever call to action stuff you have you want the robots to check out. Also I added a &lt;code&gt;/how-it-works&lt;/code&gt; page to give an explanation of the basics. In theory this could be used by an LLM to tell a user how to use the site.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;User&lt;/span&gt;-agent: *
&lt;span class="nc"&gt;Allow&lt;/span&gt;: /
&lt;span class="nc"&gt;Allow&lt;/span&gt;: /how-it-works
&lt;span class="nc"&gt;Allow&lt;/span&gt;: /llms.txt
&lt;span class="nc"&gt;Allow&lt;/span&gt;: /llms-full.txt
Disallow: /home
Disallow: /lists
Disallow: /claimed
Disallow: /admin
Disallow: /auth

Sitemap: https://wishlistpalace.com/sitemap.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These days if you want traffic to your site, not only do you want search engine crawlers to pick you up but also it would be cool if LLMs suggested people use your site. That's of course easier said than done, but it can't hurt to get some positive impression of your site in the training sets being collected. The explicit &lt;code&gt;Allow&lt;/code&gt; lines matter for training crawlers like &lt;a href="https://commoncrawl.org/" rel="noopener noreferrer"&gt;Common Crawl&lt;/a&gt; (&lt;code&gt;CCBot&lt;/code&gt;), which tend to be conservative. More on this below.&lt;/p&gt;

&lt;h3&gt;
  
  
  sitemap.xml
&lt;/h3&gt;

&lt;p&gt;A static sitemap covering every public indexable page. In Phoenix this lives in &lt;code&gt;priv/static/sitemap.xml&lt;/code&gt; and gets served by &lt;code&gt;Plug.Static&lt;/code&gt;. I give the &lt;code&gt;how-it-works&lt;/code&gt; page a higher priority than &lt;code&gt;register/sign-in&lt;/code&gt; since it has real content worth ranking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;urlset&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.sitemaps.org/schemas/sitemap/0.9"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;url&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://wishlistpalace.com/&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;priority&amp;gt;&lt;/span&gt;1.0&lt;span class="nt"&gt;&amp;lt;/priority&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;url&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://wishlistpalace.com/how-it-works&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;priority&amp;gt;&lt;/span&gt;0.8&lt;span class="nt"&gt;&amp;lt;/priority&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;url&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://wishlistpalace.com/register&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;priority&amp;gt;&lt;/span&gt;0.5&lt;span class="nt"&gt;&amp;lt;/priority&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;url&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://wishlistpalace.com/sign-in&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;priority&amp;gt;&lt;/span&gt;0.3&lt;span class="nt"&gt;&amp;lt;/priority&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/urlset&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Submit this to &lt;a href="https://search.google.com/search-console" rel="noopener noreferrer"&gt;Google Search Console&lt;/a&gt; and &lt;a href="https://www.bing.com/webmasters" rel="noopener noreferrer"&gt;Bing Webmaster Tools&lt;/a&gt;. Bing is maybe also worth it because the alternative search engines often source crawl results from Bing in some form or fashion.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON-LD structured data
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://schema.org/" rel="noopener noreferrer"&gt;Schema.org&lt;/a&gt; markup embedded in every page as a &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; tag tells Google explicitly what kind of thing this is. For a web app, &lt;code&gt;SoftwareApplication&lt;/code&gt; is the right type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SoftwareApplication"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Wish List Palace"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://wishlistpalace.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"applicationCategory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LifestyleApplication"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"operatingSystem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Web"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Coordinate family gift giving without spoiling surprises. Share wish lists, claim items secretly, and avoid duplicate gifts."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"offers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Offer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"priceCurrency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can unlock app-style rich results in search. It's also increasingly read by AI-powered search tools as a structured signal about what a site does. If you later add a review system, an &lt;code&gt;aggregateRating&lt;/code&gt; property qualifies you for star ratings in search results.&lt;/p&gt;

&lt;h2&gt;
  
  
  LLM discoverability with llms.txt
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://llmstxt.org/" rel="noopener noreferrer"&gt;llmstxt.org&lt;/a&gt; standard is a convention for helping LLM-powered tools — AI chatbots, AI search engines, coding assistants — understand what a site is and how to use it. The idea is simple: serve a markdown file at &lt;code&gt;/llms.txt&lt;/code&gt; that summarises the site and links to deeper content.&lt;/p&gt;

&lt;p&gt;I serve two files:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/llms.txt&lt;/code&gt;&lt;/strong&gt; — the index, following the spec format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Wish List Palace&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; Wish List Palace is a free wish list app for families and friends...&lt;/span&gt;

&lt;span class="gu"&gt;## Docs&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Full guide&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://wishlistpalace.com/llms-full.txt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Complete guide covering all features

&lt;span class="gu"&gt;## Pages&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;How it works&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://wishlistpalace.com/how-it-works&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Step-by-step walkthrough
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Home&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://wishlistpalace.com/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Landing page
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://wishlistpalace.com/register&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: Create a free account
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;/llms-full.txt&lt;/code&gt;&lt;/strong&gt; — a comprehensive markdown guide covering every feature: creating lists, adding items (with links, priorities, and tags), sharing, and how gift claiming works privately.&lt;/p&gt;

&lt;p&gt;In Phoenix, these are static files in &lt;code&gt;priv/static/&lt;/code&gt;. The only configuration needed is adding them to the &lt;code&gt;static_paths/0&lt;/code&gt; allowlist so &lt;code&gt;Plug.Static&lt;/code&gt; serves them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;static_paths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;~w(assets fonts images favicon.ico robots.txt sitemap.xml llms.txt llms-full.txt)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Search ranking strategy
&lt;/h2&gt;

&lt;p&gt;Here is generally how I am thinking about improving the ranking of wishlistpalace. Trying to get ranked on queries like "wishlist app" which are dominated by Amazon, MyRegistry, and Giftster is probably not worth it on my budget. These sites have years of domain authority and thousands of backlinks. Competing directly for those terms is probably worthless, even in the long run.&lt;/p&gt;

&lt;p&gt;Instead I kind of want to show up for the long tail of queries. These have far less competition and are better intent-matched:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"wish list app where others can claim gifts"&lt;/li&gt;
&lt;li&gt;"how to share a christmas list without spoilers"&lt;/li&gt;
&lt;li&gt;"wish list app no duplicates"&lt;/li&gt;
&lt;li&gt;"alternative to amazon wish list"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Someone searching those phrases has exactly the problem this app solves. Ranking page 1 for five specific long-tail queries beats ranking page 4 for a head term.&lt;/p&gt;

&lt;p&gt;I can hope to have some domain authority built through backlinks from trusted external sites over time. One-time directory listings (&lt;a href="https://alternativeto.net" rel="noopener noreferrer"&gt;AlternativeTo&lt;/a&gt;, &lt;a href="https://www.saashub.com" rel="noopener noreferrer"&gt;SaaSHub&lt;/a&gt;, &lt;a href="https://www.producthunt.com" rel="noopener noreferrer"&gt;Product Hunt&lt;/a&gt;) and community posts (Hacker News Show HN, relevant subreddits) are what I aim for though I'm waiting to finish some more features before engaging with those. But even a few backlinks over time can compound.&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>phoenix</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Github Actions for Phoenix App Deployment to Hetzner</title>
      <dc:creator>jsph</dc:creator>
      <pubDate>Sat, 11 Apr 2026 03:45:18 +0000</pubDate>
      <link>https://forem.com/jsph/github-actions-for-phoenix-app-deployment-to-hetzner-2km4</link>
      <guid>https://forem.com/jsph/github-actions-for-phoenix-app-deployment-to-hetzner-2km4</guid>
      <description>&lt;p&gt;Recently I started hosting &lt;a href="https://wishlistpalace.com/" rel="noopener noreferrer"&gt;wishlist palace&lt;/a&gt; on a Hetzner CX23 VPS (that has 4 GB of ram and 2 vcpu). At work usually I package software into a container before deploying it. One of the reasons I chose Elixir and Phoenix as the tech stack for wishlist palace is that I wanted to try out &lt;code&gt;mix&lt;/code&gt; releases which prepare binaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  OS and chip architecture
&lt;/h2&gt;

&lt;p&gt;Originally I was just going to compile on my local machine (A thinkpad T480) and upload to the server. But this didn't work because the VPS I had provisioned was running ubuntu 24.04 LTS and my local machine is on EndevousOS (a flavor of Arch by-the-way). So rather than local build and push, I opted to get claude to build a series of github actions that would build on a worker that was running ubuntu. This had the added benefit of basically completely setting up a basic CI loop so that when a release is cut we build a new binary and deploy it on the server.&lt;/p&gt;

&lt;p&gt;Elixir's &lt;code&gt;mix release&lt;/code&gt; creates a self-contained binary that bundles the Erlang VM along with the compiled application. The server needs no Elixir or Erlang installed (just a matching Linux architecture which as I said before tripped me up a bit).&lt;/p&gt;

&lt;h2&gt;
  
  
  Release Strategy
&lt;/h2&gt;

&lt;p&gt;The release is pushed to the server with rsync, which only transfers changed files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; &lt;span class="nt"&gt;--delete&lt;/span&gt; _build/prod/rel/wish_list/ deploy@&lt;span class="nv"&gt;$VPS_IP&lt;/span&gt;:/opt/wish_list/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Process management with &lt;code&gt;systemd&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The phoenix app runs as a systemd service so one uses &lt;code&gt;systemctl&lt;/code&gt; to interact with processes under its control. In the future I might setup something more like a blue-green deploy but for now I just restart the service after rsync updates the binary. The github action below restarts the service to pick up the changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrations
&lt;/h3&gt;

&lt;p&gt;Of course I also have a postgreSQL database running on the VPS so I need to do migrations from time to time. Migrations are run via the release's eval command, calling a specific release command within my application code and run directly against the production database. For me that looks like&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/opt/wish_list/bin/wish_list &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"WishListDomain.Release.migrate()"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  CI/CD: GitHub Actions on Release
&lt;/h1&gt;

&lt;p&gt;Deployments are triggered by publishing a GitHub Release. I like to have some testing before deploy just to catch catastrophic failures on my part so the first step runs tests. Then if tests are green we can build, compile, deploy, migrate, and restart the service.&lt;/p&gt;

&lt;h3&gt;
  
  
  Job 1 — Test
&lt;/h3&gt;

&lt;p&gt;Spins up a Postgres 16 service container, installs Elixir 1.19 / OTP 27, runs migrations, and executes the test suite.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;

  &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test&lt;/span&gt;
      &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-24.04&lt;/span&gt;

      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
            &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
            &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wish_list_test&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;5432:5432&lt;/span&gt;
          &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
            &lt;span class="s"&gt;--health-cmd pg_isready&lt;/span&gt;
            &lt;span class="s"&gt;--health-interval 10s&lt;/span&gt;
            &lt;span class="s"&gt;--health-timeout 5s&lt;/span&gt;
            &lt;span class="s"&gt;--health-retries 5&lt;/span&gt;

      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;MIX_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
        &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ecto://postgres:postgres@localhost/wish_list_test&lt;/span&gt;
        &lt;span class="na"&gt;TOKEN_SIGNING_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test_token_signing_secret&lt;/span&gt;

      &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Elixir&lt;/span&gt;
          &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;erlef/setup-beam@v1&lt;/span&gt;
          &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;elixir-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.19"&lt;/span&gt;
            &lt;span class="na"&gt;otp-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;27"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install deps&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mix deps.get&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Compile&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mix compile --warnings-as-errors&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run migrations&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mix ecto.migrate --repo WishListDomain.Repo&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mix test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Job 2 — Build &amp;amp; Deploy (runs only after test passes)
&lt;/h3&gt;

&lt;p&gt;Builds the production release, opens an SSH connection to the Hetzner VPS, rsyncs the release binary, runs migrations, restarts the systemd service, and verifies it came up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;build-and-deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build &amp;amp; Deploy&lt;/span&gt;
      &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-24.04&lt;/span&gt;
      &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;MIX_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;

      &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Elixir&lt;/span&gt;
          &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;erlef/setup-beam@v1&lt;/span&gt;
          &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;elixir-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.19"&lt;/span&gt;
            &lt;span class="na"&gt;otp-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;27"&lt;/span&gt;

        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install deps&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mix deps.get --only prod&lt;/span&gt;

        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build assets&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cd apps/wish_list_web &amp;amp;&amp;amp; mix assets.deploy&lt;/span&gt;

        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build release&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mix release wish_list&lt;/span&gt;

        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up SSH&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;mkdir -p ~/.ssh&lt;/span&gt;
            &lt;span class="s"&gt;echo "${{ secrets.SSH_PRIVATE_KEY }}" &amp;gt; ~/.ssh/id_ed25519&lt;/span&gt;
            &lt;span class="s"&gt;chmod 600 ~/.ssh/id_ed25519&lt;/span&gt;
            &lt;span class="s"&gt;ssh-keyscan -H ${{ secrets.VPS_IP }} &amp;gt;&amp;gt; ~/.ssh/known_hosts&lt;/span&gt;

        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sync release to VPS&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;rsync -az --delete \&lt;/span&gt;
              &lt;span class="s"&gt;_build/prod/rel/wish_list/ \&lt;/span&gt;
              &lt;span class="s"&gt;deploy@${{ secrets.VPS_IP }}:/opt/wish_list/&lt;/span&gt;

        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run migrations&lt;/span&gt;
          &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;ssh deploy@${{ secrets.VPS_IP }} \&lt;/span&gt;
              &lt;span class="s"&gt;'set -a &amp;amp;&amp;amp; source /etc/wish_list.env &amp;amp;&amp;amp; set +a &amp;amp;&amp;amp; /opt/wish_list/bin/wish_list eval&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WishListDomain.Release.migrate()"&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;

        &lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;name:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Restart&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;service&lt;/span&gt;
          &lt;span class="s"&gt;run:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;ssh&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deploy@${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;secrets.VPS_IP&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;\&lt;/span&gt;
              &lt;span class="s"&gt;'sudo systemctl restart wish_list'&lt;/span&gt;

        &lt;span class="s"&gt;- name&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify service is running&lt;/span&gt;
          &lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;ssh deploy@${{ secrets.VPS_IP }} \&lt;/span&gt;
              &lt;span class="s"&gt;'sudo systemctl is-active wish_list'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two GitHub Actions secrets are required: SSH_PRIVATE_KEY (the private key for the deploy user) and VPS_IP (the server's IP address).&lt;/p&gt;

</description>
      <category>github</category>
      <category>cicd</category>
      <category>hetzner</category>
    </item>
    <item>
      <title>How to Protect Your New VPS</title>
      <dc:creator>jsph</dc:creator>
      <pubDate>Sat, 04 Apr 2026 04:03:29 +0000</pubDate>
      <link>https://forem.com/jsph/how-to-protect-your-new-vps-1pnj</link>
      <guid>https://forem.com/jsph/how-to-protect-your-new-vps-1pnj</guid>
      <description>&lt;p&gt;I was very intimitaded about using a VPS outside a VPC my web apps. I had never used a stand alone VPS without some kind of walled garden around it. At work everything is usually behind a VPC. And I have always worked with awesome security people to double check things! I don't have to worry so much about bots or people constantly trying to exploit my service (of course we follow guidence from our excellent security colleagues). But when you turn on your new VPS it immediately can be discovered and bots will immediately try to exploit vulnerabilities.&lt;/p&gt;

&lt;p&gt;Recently I launched &lt;a href="https://wishlistpalace.com" rel="noopener noreferrer"&gt;wishlistpalace.com&lt;/a&gt; which is running on a Hetzner VPS and I have come to find out that things are not quite a dire or dangerous as I had thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSH vulnerabilities
&lt;/h2&gt;

&lt;p&gt;One flavor of exploit that bots try against your VPS is to try a bunch of passwords to try to be able to ssh into your new VPS and do whatever (maybe start mining bitcoin or installing malware). Thankfully, VPS providers don't just spin up your box with weak passwords and call it a day. In fact it's even better than that on Hetzner. They let you explicit disable ssh password authentication so this exploit simply won't happen to your new box.&lt;/p&gt;

&lt;p&gt;But then you might ask, how do I get into my box then? With the key of course. By default the setup wizard on the Hetzner site for new VPS asks you to provide a public ssh key. You can use&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/name_of_key &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"your new hetzner box"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to generate a public and private key pair. Then during the setup flow you upload the PUBLIC key.&lt;/p&gt;

&lt;p&gt;Later once your box is up you will be able to get the IP for the VPS. Shelling into your box can be made super simple by setting up your ssh config. Add an entry to &lt;code&gt;~/.ssh/config&lt;/code&gt; so SSH uses it automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; someNameOfYourBox
    &lt;span class="k"&gt;HostName&lt;/span&gt; &amp;lt;your-vps-ip&amp;gt;
    &lt;span class="k"&gt;User&lt;/span&gt; root
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/name_of_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just replace the place holder values above of course!&lt;/p&gt;

&lt;h2&gt;
  
  
  Bot protection
&lt;/h2&gt;

&lt;p&gt;Another probably unfounded fear was that as soon as I turned on my VPS there would be a DDoS or some other overwhelm attack. That I thought this probably means I think a little too highly of myself sure there will be pokes at the new IP but probably not a DDoS right out of the gate. Never-the-less I learned about a few bot protection measures.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloudflare
&lt;/h3&gt;

&lt;p&gt;This is probably obvious to most, but if you are really worried about bots, the cloudflare is the way the go. And they have a free tier. I won't list the full instructions on how to setup here, but there are lots of good tutorials I found on line, and Claude was helpful in getting this setup.&lt;/p&gt;

&lt;p&gt;I chose also to use nginx in front of my app so I needed to configure the handshake between cloudflare and nginx.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;fail2ban&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Something you can run on the new VPS is &lt;code&gt;fail2ban&lt;/code&gt; which monitors login attempts and temporarily bans IPs that fail too many times. It's kind of fun to use because after a few days you can the thousands of failed attempts to get into your machine from bots.&lt;/p&gt;

&lt;p&gt;The VPS I set up is using Ubuntu 24.04 LTS and setting up fail2ban is as simple as&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;fail2ban
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After about a week after I started the VPS here's what I see when I check in on &lt;code&gt;fail2ban&lt;/code&gt; with &lt;code&gt;sudo fail2ban-client status sshd&lt;/code&gt; to check attacks against ssh.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Status for the jail: sshd
|- Filter
|  |- Currently failed: 1
|  |- Total failed:     3487
|  `- Journal matches:  _SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
   |- Currently banned: 0
   |- Total banned:     164
   `- Banned IP list:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wow! I'm so thankful bots wanted to try to shell into my VPS!&lt;/p&gt;

&lt;h3&gt;
  
  
  Firewall
&lt;/h3&gt;

&lt;p&gt;Ubuntu has a firewall utility called &lt;code&gt;ufw&lt;/code&gt; and is a quick way to help protect the service you want to run on the VPS. After all I wanted to host wishlist palace the application on the VPS that was the whole point. I chose Elixir's Phoenix framework as the web service part of the app and it exposes the port 4000. But as soon as I spin it up it is possible for people to try some exploit against this port.&lt;/p&gt;

&lt;p&gt;But with a firewall and using cloudflare and nginx we can really lock down direct access to the app. The desired state would ensure that the only traffic to the app has to travel through our protective layer above, i.e., cloudflare and nginx.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph LR
  User --&amp;gt;|":443 HTTPS"| Cloudflare
  Cloudflare --&amp;gt;|":443 HTTPS"| nginx
  nginx --&amp;gt;|":4000 HTTP"| Phoenix
  nginx --&amp;gt;|":80 HTTP redirect"| Cloudflare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To achieve this we can setup the firewall to limit to only the ports we need for this desired access pattern. With a few invocations of &lt;code&gt;ufw&lt;/code&gt; you can lock access down. But it is important to leave the ssh port open so you can get back in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw allow 22    &lt;span class="c"&gt;# SSH — do this FIRST or you'll lock yourself out&lt;/span&gt;
ufw allow 80    &lt;span class="c"&gt;# HTTP — needed for Certbot's verification step&lt;/span&gt;
ufw allow 443   &lt;span class="c"&gt;# HTTPS — your app's public port&lt;/span&gt;
ufw &lt;span class="nb"&gt;enable
&lt;/span&gt;ufw status      &lt;span class="c"&gt;# confirm rules are active&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This basically sets up an allow list then locks every other port down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maybe not so scary after all
&lt;/h2&gt;

&lt;p&gt;That's all I did in the first week of having my VPS and so far it hasn't crashed so maybe I was wrong to be worried. I have no doubt there are other important security techniques I need to learn about but I wanted to share this setup.&lt;/p&gt;

&lt;p&gt;I think I might follow up with more details about the cloudflare nginx setup in another post.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>linux</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Use Augmented Coding to Build a Web App</title>
      <dc:creator>jsph</dc:creator>
      <pubDate>Wed, 01 Apr 2026 02:59:12 +0000</pubDate>
      <link>https://forem.com/jsph/how-to-use-augmented-coding-to-build-a-web-app-3o5g</link>
      <guid>https://forem.com/jsph/how-to-use-augmented-coding-to-build-a-web-app-3o5g</guid>
      <description>&lt;p&gt;I recently launched &lt;a href="https://wishlistpalace.com" rel="noopener noreferrer"&gt;wishlist palace&lt;/a&gt; which is a web app to help share information with friends and family about birthday lists and christmas lists. This app is my first exploration into using Augemented coding (which is a term I prefer over vibe coding).&lt;/p&gt;

&lt;p&gt;When I first heard of vibe coding, I wasn't particularly interested in engaging with it as a mode of production I use myself. I enjoy the problem solving aspect of software engineering and at face value much of that process falls into the lap of the LLM when doing vibe coding. And it's not only the solution of the problem at hand that is of interest to me but directly engaging and phrasing that problem in the formalisms of programming languages scartches an itch in the same way I image poets feel when they want to express themselves in poetry. The trouble with the situation we (as tech folk) are in now is that the terms (like vibe and augmented coding) are highly overloaded and are used to described vastly different development cycles from the one shot prompt of 100 words attempting to get a polished app to the very detailed prompt to obtain the 10 line fix you requested in a few hundred words.&lt;/p&gt;

&lt;p&gt;Accompanying these observations are claims that LLMs are unlocking (&lt;em&gt;insert large integer&lt;/em&gt;) times speed up in product development and claims that the landscape of software development has fundamentally shifted and there will be no jobs and we are on the cusp of fully automating the production of software for all use cases. Then there are people I respect deeply saying they will never touch LLMs and will categorically reject any code which smells as if it was touched by generative AI. Not to mention important ethical considerations that have been raised about AI use. All this conflicting information has left me with a bit of confusion.&lt;/p&gt;

&lt;p&gt;Rather than try to parse what is globally true about LLMs and their use to generate software, I thought I'd see how I could use to solve a problem I have (and a somewhat mundane problem at that). Then with this as at least one data point I can start to see where I land in the vast spectrum of AI/LLM assessment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing frameworks
&lt;/h2&gt;

&lt;p&gt;The choice of tech stack will have a big impact on what you can expect from the LLM. For wishlist palace I went with technologies I have some knowledge of and also that I like the philosophy of&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Elixir &lt;code&gt;mix&lt;/code&gt; build automation tool and functional programming&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Ash Framework&lt;/code&gt; for domain modeling and also for it's &lt;code&gt;ash_authentication&lt;/code&gt; library&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Pheonix&lt;/code&gt; for the web server and to use it's LiveView for realtime updates to wish lists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In theory an LLM could generate for you a bespoke programming language for your project. But just as humans often choose to leverage what has already worked because they can benefit from what other's have developed so too there is obvious benefit of choosing what has been proven to work when doing augmented coding. Since the popularity of a framework or language is a proxy for how much training data the LLM had access to, I think it's also important to make a choice based on systems you like to work with. For me that's functional programming. I have learned some &lt;code&gt;elixir&lt;/code&gt; doing some side projects but I've never released a web app in the language. I chose elixir because I am intereseted in the &lt;a href="https://www.erlang.org/blog/a-brief-beam-primer/" rel="noopener noreferrer"&gt;BEAM virtual machine&lt;/a&gt; and it's cool features like hot code loading and it's concurrency features. So I was a bit familiar with Elixir and have used the webframework Phoenix a bit before (but never in production) so they were reasonable candidates for the prospect of evaluating the generated output from the LLM.&lt;/p&gt;

&lt;p&gt;There is a big difference between generating code in a language or framework that you have close to zero context yourself in and generating it in a context in which you have deep knowledge. In my professional work I use python primarily and work on backend systems. I feel much more confident in that scenario when evaluating LLM generated plans and code given that I just have much more experience building with. I have much less experience building front end features. I'm still a bit scared to modify css files and JS is not a strong suite of mine. So I knew I was going to have a harder time evaluating the quality of the front end components generated by an LLM. But Pheonix has &lt;code&gt;HTML HEEx (HTML + Embedded Elixir)&lt;/code&gt; which is not such a stretch for me, I do know some HTML (certainly I don't know all of the features of HTML though).&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with core data structures
&lt;/h2&gt;

&lt;p&gt;A strategy I think is helpful is to first build the core data structures and transformations on these data strucutres that you app will require. This way you can reason about if you have the right data strucutures before jumping into designing the user experience.&lt;/p&gt;

&lt;p&gt;This strategy fits well with the philosophy of &lt;a href="https://ash-hq.org/" rel="noopener noreferrer"&gt;Ash Framework&lt;/a&gt; which is well suited to domain driven design. I like Ash because it gives you an easy way to declare what the shape of the data looks like (as &lt;code&gt;attributes&lt;/code&gt;), the transformations that can be applied to them (as &lt;code&gt;actions&lt;/code&gt;), and how it will be persisted (with Ash's &lt;code&gt;Repo&lt;/code&gt; abstraction).&lt;/p&gt;

&lt;p&gt;So I built first the concepts of &lt;code&gt;List&lt;/code&gt; and &lt;code&gt;Item&lt;/code&gt; which are funnily enough very similar to the classic TODO list app that is usually the subject of alot of language and framework tutorials!&lt;/p&gt;

&lt;p&gt;For the key actions that need to be applied to lists there are the classic CRUD ones, but also I wanted to add the notion of having a user "claim" an item. This is important for birthday and christmas lists so that who gift givers don't get the same gift. This was the first action and attributes for these items which depart from bog standard CRUD operations on data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt for small features
&lt;/h2&gt;

&lt;p&gt;The ideal time to introduce an LLM tool to develop features is after you have the core data structures and how to manipulate at least somewhat complete. The data structures and the actions you want to take on them may evolve overtime, but it's difficult to describe both a desired feature and a proper data model in a single prompt so that's why I like to do it in this order.&lt;/p&gt;

&lt;p&gt;Then in my experience it is best not to try to prompt to jump straight to the finished product you have in mind. Try to break up the jump to your vision of the web app into smaller chunks. This is of course no surprise to people who have been developing software for a while and is a best practice not because it is written in a book somewhere but because it makes each change easier to reason about for both humans and for machines.&lt;/p&gt;

&lt;p&gt;But just because you want to break up the changes into discrete steps doesn't mean they have to be tiny in terms of line count or even in terms of impact. Just conceptually smallish! For my case one of the first additional features I wanted to develop after the data foundation had been laid was to add users and the pheonix views to be able to create lists and add items.&lt;/p&gt;

&lt;p&gt;On the one hand this is a big change from what I already had, I didn't set up Phoenix yet, I didn't have users abstraction, and I didn't have any of the front end templates setup. But the quality of the current models is such that you can prompt in a way that leverages the frameworks you have chosen. Ash framework and Phoenix are known to work well together and Phoenix is very much ready to support the notion of users and users wanting to do CRUD actions on data. So even though there were quite of few lines that need to be written conceptually this was not a large jump and the model handled it well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test as you go
&lt;/h2&gt;

&lt;p&gt;After you have a chunk of functionality up using classic testing practices is a good way to verify things are going well. I used a mix of unit testing and "integration" tests against a locally running db to make sure the functional behavior was correct. In addition &lt;code&gt;Phoenix&lt;/code&gt; has excellent tooling for running the app locally so it's easy to very user workflows from the locally running app in your own browser.&lt;/p&gt;

&lt;p&gt;I don't think there is too much difference in how to test for hand written versus augmented coding techniques. The main question is how much you rely on the model to write the tests. I find that it's best to really interrogate the model about the usefulness of the test as they tend to write alot of tests that don't really test any meaningful behavior of the system. Fewer but more impactful tests is better than 100 tests that don't really catch regressions or are annoying to update. Again this a classic insight from software engineering that is probably widely known.&lt;/p&gt;

&lt;h2&gt;
  
  
  Learning by doing?
&lt;/h2&gt;

&lt;p&gt;One of the goals I had this with project was to teach myself more about the front end bits of pheonix. The classic approach to learning a new technology is to build something with it. This naturally takes you on a journey to discover how to use the thing. But with LLMs, I notice that the learning by doing is much less impactful than doing something by hand. I think this is a large trade-off. Of course I ended with a functional program much faster with LLM usage, but I don't feel deeply knowledgable about HTML HEEx yet. I know more than I did to start with but I feel not as much as if I had done it "the old fashioned way." So learning outcomes of building this project were not as good as other methods.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>programming</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Setting up a hugo static site hosted with Porkbun</title>
      <dc:creator>jsph</dc:creator>
      <pubDate>Wed, 01 Apr 2026 02:59:11 +0000</pubDate>
      <link>https://forem.com/jsph/setting-up-a-hugo-static-site-hosted-with-porkbun-1lp9</link>
      <guid>https://forem.com/jsph/setting-up-a-hugo-static-site-hosted-with-porkbun-1lp9</guid>
      <description>&lt;h2&gt;
  
  
  Content generation
&lt;/h2&gt;

&lt;p&gt;This is a static site generated with &lt;a href="https://gohugo.io/getting-started/quick-start/" rel="noopener noreferrer"&gt;hugo&lt;/a&gt; with the PaperMod theme. I wanted an easy to use static site generator. I considered &lt;a href="https://jekyllrb.com/" rel="noopener noreferrer"&gt;Jekyll&lt;/a&gt; and believe it to be a good choice for static sites. There seemed to be slightly more themes I liked with hugo so I went with that. That's a pretty superficial choice but I also don't plan on hacking on the site generation itself so I was agnostic to the Go versus Ruby choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Domain hosting
&lt;/h2&gt;

&lt;p&gt;This site uses &lt;a href="https://porkbun.com" rel="noopener noreferrer"&gt;porkbun&lt;/a&gt; for a domain host. I chose it not least because I do enjoy porkbuns. They also listed static site hosting as a service which suited this site well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Porkbun link-in-bio versus static hosting
&lt;/h2&gt;

&lt;p&gt;By the time I wanted to host this site, I either made the choice without remembering or it is a default setting to have new sites use the "link in bio" hosting plan (which is free). But I need to pay their small fee to host a static site. I then found porkbun's helpful &lt;a href="https://kb.porkbun.com/article/137-how-to-set-up-static-hosting" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt;, which helped me change out of link in bio mode to static hosting mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Github Integration
&lt;/h2&gt;

&lt;p&gt;If you don't want to upload your files directly from your computer Porkbun offers GitHub connect to automatically publish changes to the site. But I had some confusion about how the generated site should sit in the directory structure. The integration let's you pick a repository and a branch on that repository to watch.&lt;/p&gt;

&lt;h2&gt;
  
  
  File structure Porkbun expects
&lt;/h2&gt;

&lt;p&gt;I first chose &lt;code&gt;main&lt;/code&gt;, but the integration didn't appear to work. The issue I think is the strucuture of the files for the static site that Porkbub accepts. By default &lt;code&gt;hugo&lt;/code&gt; exports the generated static files to a directory &lt;code&gt;public&lt;/code&gt;. The file hierarchy roughly looks like the following after you create a new project and render the files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.
├── archetypes
│   └── default.md
├── assets
│   └── css
├── content
│   └── posts
├── data
├── hugo.toml
├── i18n
├── layouts
├── public
│   ├── 404.html
│   ├── assets
│   ├── categories
│   ├── index.html
│   ├── index.xml
│   ├── page
│   ├── posts
│   ├── sitemap.xml
│   └── tags
├── static
└── themes
    └── PaperMod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can see that the &lt;code&gt;public&lt;/code&gt; directory contains all the files and directories for the site.&lt;/p&gt;

&lt;p&gt;Some other static file hosting servies let you point to a directory that contains the files for the static site, but it appears that Porkbun expects that these files be at the root of the github repo and branch you point it. Rather than overwrite main to just have the contents of &lt;code&gt;public&lt;/code&gt; we can create another branch that contains only what we need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up a deploy branch
&lt;/h2&gt;

&lt;p&gt;I had Claude generate a github action to create the generated files in a different branch &lt;code&gt;deploy&lt;/code&gt;. Something like &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;submodules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Hugo&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;peaceiris/actions-hugo@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;hugo-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latest'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hugo --minify&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to deploy branch&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;peaceiris/actions-gh-pages@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;github_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;publish_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./public&lt;/span&gt;
          &lt;span class="na"&gt;publish_branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
          &lt;span class="na"&gt;force_orphan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I set the branch to watch to &lt;code&gt;deploy&lt;/code&gt; in the porkbun UI and that did the trick. There were a few error messages but it seemed to not matter because now you are reading this and so it worked.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
