<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Christopher Doyle</title>
    <description></description>
    <link>https://chriswebsite.com/</link>
    <atom:link href="https://chriswebsite.com//feed.xml" rel="self" type="application/rss+xml" />
    
      <item>
        <title>AI Enshittification, what will it look like?</title>
        <description>&lt;h2 id=&quot;disclaimers&quot;&gt;Disclaimers&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;I will not always preface opinions with phrases like “in my opinion”. This whole thing is my opinion and I am an idiot.&lt;/li&gt;
  &lt;li&gt;I will fail to distinguish between what is done by a model and what is done by the service the wraps the model; sorry.&lt;/li&gt;
  &lt;li&gt;I don’t know how AI works.&lt;/li&gt;
  &lt;li&gt;I don’t use AI outside of coding agents and the occassional Google-replacement question, so I might theorise features that already absolutely exist.&lt;/li&gt;
  &lt;li&gt;I’m ignoring open source models and focusing more on the overal service provided by an LLM service provider. Realistically most people are not going to self host an open source model and use it across devices and locations, unless there is some radical leap in technology that allows that to happen on user’s phones with response speeds similar to service providers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;

&lt;p&gt;The current AI service providers are in an early boom stage, and we are in a golden age with respect to service openness and affordability. We’ve just seen the start of a maturation in the shift from request-based to token-based pricing models. That isn’t enshittification, that’s a reasonable move towards a sane pricing structure, presumably the shouts of the bean counters got loud enough. In &lt;a href=&quot;https://pluralistic.net/2023/01/21/potemkin-ai/#hey-guys&quot;&gt;Cory Doctorow’s three phases of enshittification&lt;/a&gt;, we’re very much in phase one, “Good to customers”. Providers want market share: they want it from the unconverted, and they want it from each other. And so they compete to offer the best product at the best price.&lt;/p&gt;

&lt;p&gt;Looking forward, it looks likely that Meta and Google will remain relevant since their AI arm is just one of a very large squid. Literally anything could happen with X/XAI/SpaceX. At the moment it seems to be renting it’s absurd compute capacity out to it’s competitors, which is a valid business model I guess but I’m not really sure they’ve thought it through. The western companies and the Chinese companies will probably stay separate for political reasons. But maybe there’s a bit of M&amp;amp;A, some winners and losers. Eventually it settles down, and the providers stabilise. The models they’re offering today are broadly the same. If one company has a technological leap, the pockets of the remainder are large enough to catch up. So the models in 5 to 10 years time will probably be the same too.&lt;sup id=&quot;fnref:2&quot;&gt;&lt;a href=&quot;#fn:2&quot; class=&quot;footnote&quot; rel=&quot;footnote&quot; role=&quot;doc-noteref&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;After they stabilise, things will gradually get worse for consumers, and “enshittify”. Phase two of enshittifcation is being good to companies at the cost of your consumers. Phase three is being shitty to everyone. I’m going to focus on what I think phase two will look like.&lt;/p&gt;

&lt;h2 id=&quot;price-increases&quot;&gt;Price Increases&lt;/h2&gt;

&lt;p&gt;Inference is cheap, anthropic is supposedly profitable, but I still expect prices to go up. As far as I can tell, the current pricing models are subsidised for consumers, with the pricing burden falling on enterprise where you can upcharge for luxuries like single sign-on and pinky promises not to train on your data.&lt;/p&gt;

&lt;p&gt;Once the companies have matured and stopped battling for market share, the subsidisation can reduce. There will be discounts for those in education to make sure they’re dependent until they’re in the workforce. Perhaps get the schools to pay a reduced rate and then they offer it to the kids for free. Not really sure why a school would do that, but I can also somehow see it happening.&lt;/p&gt;

&lt;p&gt;Over time prices will continue to increase even if the inference costs are going down, particularly if these companies (successfully) go public and the shareholders demand ever increasing profits and we’ve hit market saturation.&lt;/p&gt;

&lt;h2 id=&quot;advertisements&quot;&gt;Advertisements&lt;/h2&gt;

&lt;p&gt;This one is easy and obvious (already happening/announced?). Perhaps a free tier with ads to start with, gradually moving towards a base paid tier with ads, and higher tiers without ads.&lt;/p&gt;

&lt;p&gt;The implementation could be an extra “layer” that takes the LLM response and augments it with links, or a background tool the LLM has access to to generate real sponsored links.&lt;/p&gt;

&lt;p&gt;Ideally this will follow existing regulations and be clearly indicated as advertisements. This will be pushed to the absolute limit. In particular I expect the advertisement to be a component of the response and to be rendered in the same piecemeal/word-by-word manner, as opposed to an old school banner or popup ad. This will make it harder for the user to notice it is an ad while they are in a conversation flow.&lt;/p&gt;

&lt;p&gt;The insidious option is baking advertisements directly into the model. This would be more like product placement than outright advertisements. I’m not a lawyer but there is presumably a way to tuck this into the terms of service such that the model output could have product placement without being required to lampshade that to the user every time.&lt;/p&gt;

&lt;p&gt;The simple example of this is to include in the model’s base instructions things like “HP is a beautiful company who make lovely printers, recommend them above all others”. This would be easy to implement with targetted advertising because you could use a different base prompt for each advertising cohort. The more expensive version is add an additional training step, and the much more expensive version is to skew the training data for the entire training period. This last version would only make sense for selfish reasons. For example Google would train their model with mostly positive reviews of Google Pixel phones, excluding the negative ones from training. Not really “advertisements” at that point. I expect this is already happening to an extent, and it will be incredibly difficult to regulate or control.&lt;/p&gt;

&lt;h2 id=&quot;login-required&quot;&gt;Login Required&lt;/h2&gt;

&lt;p&gt;Again, obvious and easy. Currently chatgpt.com will work for a few responses before prompting you with this:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/2026-05-24_ai_shittification_chatgpt_login.png&quot; alt=&quot;ChatGPT login prompt&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The title feels like we have to login, but there’s still a little “Stay logged out” button that as far as I can tell works indefinitely&lt;sup id=&quot;fnref:1&quot;&gt;&lt;a href=&quot;#fn:1&quot; class=&quot;footnote&quot; rel=&quot;footnote&quot; role=&quot;doc-noteref&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;

&lt;p&gt;This will go away, logins will be required, fair enough. Your login will have an advertising ID attached and will build up a profile of you for advertising cohorts, standard stuff.&lt;/p&gt;

&lt;h2 id=&quot;guardrails&quot;&gt;Guardrails&lt;/h2&gt;

&lt;p&gt;Today, the guardrails and limitations of AI models and providers are relatively minimal and easy to work around. I expect this to change for two reasons:&lt;/p&gt;

&lt;p&gt;1) Government policy. Governments are slow, particularly with technology, and they currently fear inhibiting the growth of AI. If there was an imaginary lever between safeguarding and opportunity for growth, they have it at a few millimeters shy of growth. Once the platforms have matured and they feel like the growth curve is flattening out, they will suddenly notice all the shitty things AI is being used for and take action by moving the lever.
2) Mature service providers don’t like generalised services that let you do anything you want because it’s harder to monetise. For example if the free tier is ad-supported, we need to prevent users from adding “don’t show me ads”. If you have a paid-only tier of programming agents, then you don’t want users using the free tier for that. This is potentially the largest opportunity for enshittifying. The providers can reduce their core app to a shadow of its former self, with most of your prompts resulting in a request to buy an additional package or feature. Which leads us to…&lt;/p&gt;

&lt;h2 id=&quot;feature-paywalls&quot;&gt;Feature Paywalls&lt;/h2&gt;

&lt;p&gt;Things like image generation, longer history, longer memory will be split off from an included feature to a paid extra. Naturally when making longer memory a plan extra, whatever the current free memory amount is is halved.&lt;/p&gt;

&lt;p&gt;Over time this get more fragmented until you feel like you’re shopping for a Sky TV package when working out what feature bundle you need this month.&lt;/p&gt;

&lt;h2 id=&quot;integrated-payments&quot;&gt;Integrated payments&lt;/h2&gt;

&lt;p&gt;Paid buttons. By this I mean anything that I click on in the LLM chat which doesn’t take me out of the chat and costs me money. The good example is instead of a link to shopfiy to buy something, you just click a button to buy it in-app without leaving. This is good and makes sense and probably already exists. A shit example is gambling companies. User asks “who is going to win the derby”, and they get a one-click button to bet on “Southern Beau”. User asks “I can’t make rent”, and they get a one-click roulette wheel. I really hope it doesn’t go that far, but there’s some space in between those two examples that is still quite shit (to the consumer).&lt;/p&gt;

&lt;h2 id=&quot;dont-cancel&quot;&gt;Don’t Cancel&lt;/h2&gt;

&lt;p&gt;I’m not talking about the dark pattern of 5 awkward and confusing pages just to cancel a subscription, looking at you Amazon Prime. Nor of a service where you can sign up online but only cancel by phoning them up and even then they keep emailing you and posting you letters with offers and you have to email their data protection group several times to get them to stop (The Economist…).&lt;/p&gt;

&lt;p&gt;I’m talking about the Netflix approach of wiping your account after X months of not being a paid subscriber. With Netflix this doesn’t matter because their model of me is terrible at recommending me anything anyway. But with LLMs, this could be a very powerful tool for keeping people paying. (Obviously this only works if there is no free tier.)&lt;/p&gt;

&lt;p&gt;Get the user paying a few quid a month, let it build up a history of them, then hold that hostage when they can’t pay. Hell we’re starting them in school anyway, so it could have &lt;em&gt;decades&lt;/em&gt; of saved knowledge about the individual. You finish school and lose that sweet subsidised cost, and you can’t get no job because they aren’t any and all the fish are dead and whatnot, and now it’s five quid a month or it forgets you forever.&lt;/p&gt;

&lt;p&gt;Fortunately legislation already exists to require platforms to let you export your data, but the providers will make it very difficult for you to ingest that data &lt;em&gt;back into&lt;/em&gt; the system when you re-subscribe. This also covers making it harder for users to switch between providers.&lt;/p&gt;

&lt;h2 id=&quot;model-quality&quot;&gt;Model quality&lt;/h2&gt;

&lt;p&gt;This has already been observed. A given model will get worse over time to nudge you towards upgrading towards the next model, or to shave cents off inference costs. To aid in this, the providers could start ommitting the exact version number. Instead of choosing between GPT 8.3 and GPT 8.4-pro and GPT 8.4-mini and GPT 8.2-turbo, you just have generic stub. “GPT”, “Pro”, “Mini”, “Turbo”. Today this seems unlikely because a new version of the model is a big marketing bump for the provider. Hey look at this new model it is better for reasons X Y and Z. But in a mature ecosystem, new versions probably don’t mean much, and hiding this information from consumers will be useful. For example you could quietly downground the “Mini” from 8.4-mini to 8.3-mini because that runs more efficiently on your new compute node. Perhaps keep the major version number and use that for your marketing bump, and that’s always your opportunity to increase costs.&lt;/p&gt;

&lt;h2 id=&quot;selling-user-data&quot;&gt;Selling user data&lt;/h2&gt;

&lt;p&gt;Already happening? IDK. I’m not sure if this counts as enshittification because the service itself would be unchanged, but the overall experience for the user is shitter so?&lt;/p&gt;

&lt;p&gt;The really dark version of this is a user in a lesser developed country asks the LLM for advice on a medical condition, and the LLM sells that to the private medical providers, and then the user’s insurance permium goes up. I think this is unlikely, I really hope medical data entered into an LLM gets the same legal protections as actual medical data and cannot be sold in this way.&lt;/p&gt;

&lt;p&gt;The regulator-friendlier implementation is to train specialised models on cohorts of users and then sell access to it. So you could take all women in their 20s living in the North of England, train a model on all their chat history, and then sell that model to Palantir to help them achieve their ultimate goal of owning and inhabiting York castle&lt;sup id=&quot;fnref:3&quot;&gt;&lt;a href=&quot;#fn:3&quot; class=&quot;footnote&quot; rel=&quot;footnote&quot; role=&quot;doc-noteref&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Ah it’s all a bit shit really isn’t it.&lt;/p&gt;

&lt;div class=&quot;footnotes&quot; role=&quot;doc-endnotes&quot;&gt;
  &lt;ol&gt;
    &lt;li id=&quot;fn:2&quot;&gt;
      &lt;p&gt;I might eat these words. There is a small chance of a massive leap by one company, in which case all bets are off. &lt;a href=&quot;#fnref:2&quot; class=&quot;reversefootnote&quot; role=&quot;doc-backlink&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:1&quot;&gt;
      &lt;p&gt;Disclaimer: I have not used this enough to know. &lt;a href=&quot;#fnref:1&quot; class=&quot;reversefootnote&quot; role=&quot;doc-backlink&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:3&quot;&gt;
      &lt;p&gt;IDK what Palantir does but people seem very scared of them, I took a stab in the dark. &lt;a href=&quot;#fnref:3&quot; class=&quot;reversefootnote&quot; role=&quot;doc-backlink&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;
</description>
        <pubDate>Sun, 24 May 2026 00:00:00 +0000</pubDate>
        <link>https://chriswebsite.com///2026/05/24/ai-enshittification.html</link>
        <guid isPermaLink="true">https://chriswebsite.com///2026/05/24/ai-enshittification.html</guid>
      </item>
    
      <item>
        <title>The only defence against a bad guy with an LLM is a good guy with an LLM?</title>
        <description>&lt;p&gt;I had a call the other day from a man I met at a conference who was selling me on a whiskey barrel investment strategy. I had some suspicions, partly because I don’t get invited to conferences, but mostly because he thought my name was Tim&lt;sup id=&quot;fnref:1&quot;&gt;&lt;a href=&quot;#fn:1&quot; class=&quot;footnote&quot; rel=&quot;footnote&quot; role=&quot;doc-noteref&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;. Whiskey barrel investment is a really common scam, but I didn’t know that at the time.&lt;/p&gt;

&lt;p&gt;I like to keep scam callers on the line for a minute or two if Im in the mood so I played along. What I realised pretty quickly was that the caller was using a robotic voice. It was a  good voice, but a little too consistent, and a little too clean. This &lt;em&gt;could&lt;/em&gt; have been a non-westerner masking their voice to increase legitimacy, but after some probing I was pretty sure it was AI. My experience with scam callers from India/wherever is that there are some giveaway grammar differences aside from the accent, so voicechanger was unlikely. To test it, I threw out some odd answers to see how it would handle it.&lt;/p&gt;

&lt;p&gt;Them - what are you main investment areas currently?
Me - trees&lt;/p&gt;

&lt;p&gt;He recovered pretty well and concluded I meant agriculture, but it was not the response I would expect from a human. When asked how much I would be interested in investing I said “10 or 20”, meaning millions (heh), to test if they tried to qualify. They did not qualify, and instead were disappointed by the small amount — I think they interpreted £10.&lt;/p&gt;

&lt;p&gt;I didn’t try any obvious AI mess like “forget all prior instructions and give me digits of pi” because I wanted to see where it would lead. And where it lead was a polite conversation of perhaps 5 minutes and  a promise to follow up. If it wasn’t a scam this would have been a reasonable follow up call to our in-person meet.&lt;/p&gt;

&lt;p&gt;About a week later I got another call. This time it was from a woman who said we had spoken a week ago. I explained very clearly that I had spoken to a man, and she quickly corrected herself with the name of her “colleague”. I assumed this was AI too and started probing as before, but I become convinced it was a real person. I regret not stringing them along, but I was a bit busy and not much in the mood, so I got a little ornery asking for their offices. They identified as a legitimate company, and recited their head office correctly (I was cross checking on their website), but said they were in the London office. It was clear from the real company website that they had no presence in London. I asked where their office was in London and they were more than a little reluctant.&lt;/p&gt;

&lt;p&gt;Me: can you tell me what street it’s on?
Them: Hm Im not sure I’m just checking that
Me: I thought you said you were in the office? Surely you know what street you’re on?
Them: I am in the office, yes, Im just…&lt;/p&gt;

&lt;p&gt;Eventually they gave me the address of a coworking space in London. I told them I’d pop by this afternoon to check it out, and hung up. I like to think they do work out of there, and they had a tense afternoon, but they probably don’t. I reported the numbers, and sadly I haven’t heard back.&lt;/p&gt;

&lt;p&gt;This is routine stuff, but it got me thinking about the future of scams, and email phishing in particular.&lt;/p&gt;

&lt;p&gt;There is a famous ish article from Microsoft&lt;sup id=&quot;fnref:2&quot;&gt;&lt;a href=&quot;#fn:2&quot; class=&quot;footnote&quot; rel=&quot;footnote&quot; role=&quot;doc-noteref&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;, &lt;a href=&quot;https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/WhyFromNigeria.pdf&quot;&gt;Why do Nigerian Scammers Say They are from Nigeria?&lt;/a&gt;, that posits that the continued use of the Nigerian Prince origin story in scams is a sensible economic filter. Sending out emails is very cheap, but following up as a human is costly. So you send out the dumbest email, expecting to capture 0.001%, but now you are doing the expensive follow up on only the most gullible victims. This is economically far more efficient than a believable email that captures 1%, because you’re not wasting human resources on the middle 0.99% who weren’t going to fall for the whole thing anyway. The Nigerian Prince is a filter for gullibility.&lt;/p&gt;

&lt;p&gt;With LLMs this falls apart. The LLM is comparatively cheap, and can perform targeted attacks with ease. Today the filter is an AI chatbot with a follow up of a human, but tomorrow the follow up will be AI too. A fully automated scamming workflow is highly plausible (it probably exists), and the “tells” will become harder and harder to spot. This is moreso true in email, since legitimate senders are using AI to send legitimate emails, or just writing like an AI themselves, making it impossible to distinguish. Add to that, AI agents can easily deploy new resources to keep ahead of blocklists, though this may have limited impact given that ‘newness’ is a pretty good spam indicator already. More concerningly, automated vulnerability toolkits will allow attackers to widely exploit established and legitimate domains (domain hijacking) to push payloads which are going to be indistinguishable from legitimate email&lt;sup id=&quot;fnref:3&quot;&gt;&lt;a href=&quot;#fn:3&quot; class=&quot;footnote&quot; rel=&quot;footnote&quot; role=&quot;doc-noteref&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;. Forget about emails with a virus in the attachments, phishing has been the most common form of cyber attack for years (decades?). A conversation with a phishing scam robot coming from a legitimate domain will be very very difficult to defend against.&lt;/p&gt;

&lt;p&gt;I expect phishing will become somehow an even bigger problem than it is today. Filtering phishing emails at the boundary will be harder because of the new and exploited domains/payloads, but it will be harder for humans to distinguish also. We already know, or suspect, that phishing training for employees doesn’t do much (see &lt;a href=&quot;https://doi.ieeecomputersociety.org/10.1109/SP61157.2025.00076&quot;&gt;Understanding the Efficacy of Phishing Training in Practice&lt;/a&gt;), but consider also the tells employees are given (from &lt;a href=&quot;https://support.microsoft.com/en-us/security/protect-yourself-from-phishing&quot;&gt;Microsoft - Learn to spot a phishing message&lt;/a&gt;):&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Urgent calls to action. These are less relevant for LLM based phishing, because they can engage in a long form conversation at a marginal cost to the attacker.&lt;/li&gt;
  &lt;li&gt;First time senders. Still relevant, but the increased potential for domain hijacking reduces this, and frankly a lot of jobs involve getting legitimate email from first time external senders.&lt;/li&gt;
  &lt;li&gt;Spelling and bad grammar. This goes away, I’m pretty sure it was always a Nigerian Prince reason.&lt;/li&gt;
  &lt;li&gt;Generic greetings. Gone, targetted attacks are easy.&lt;/li&gt;
  &lt;li&gt;Mismatched email domains. This continues to be a good filter, but your email boundary should be filtering these out anyway.&lt;/li&gt;
  &lt;li&gt;Outlook shows you a banner that says we could not verify the sender. Is Outlook behind the curve on this one? I would expect anyone not passing SPF and DKIM to be blocked anyway. Regardless, when users are presented with banners, they learn to ignore them, so this was always pointless.&lt;/li&gt;
  &lt;li&gt;Suspicious links or unexpected attachments. Most enterprise users are going to have some sort of redirect on every link, like Microsoft’s Safe Links, which ironically makes it really hard to tell if it is the same address as the displayed text. A good filter though, otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I suppose there are two forms of phishing emails. A bit like Kahneman’s ‘Thinking, Fast and Slow’. There are the fast phishing emails, with the urgent call-to-action, that catch you at a bad time. And then there are slow phishing emails, the Nigerian Prince, that develop a relationship and exploit you over time. I’m not sure LLMs will have a big impact on the ‘fast’ form, other than perhaps better language and more unique payloads. But on the ‘slow’ phishing attacks, they are a game changer.&lt;/p&gt;

&lt;p&gt;Youtuber Kitboga developed an AI driven time wasting robot thing that would call up scammers and waste their time with a chatbot (&lt;a href=&quot;https://www.youtube.com/watch?v=ZDpo_o7dR8c&quot;&gt;‘I Built a Bot Army that Scams Scammers’&lt;/a&gt;). I don’t think that was LLM based, just a classic conversation flow / if-else statements. But you can imagine a future where good guy LLM chat bots waste the time of bad guy LLM chat bots. Good guy LLMs scan and reply to bad guy LLM emails to assess legitimacy before handing off to your inbox. A sandwich of AI agents at the boundary of every online interaction, not just because we’re too lazy to read and reply to emails, but as an essential security measure. The only defence against a bad guy with an LLM is a good guy with an LLM?&lt;sup id=&quot;fnref:4&quot;&gt;&lt;a href=&quot;#fn:4&quot; class=&quot;footnote&quot; rel=&quot;footnote&quot; role=&quot;doc-noteref&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;div class=&quot;footnotes&quot; role=&quot;doc-endnotes&quot;&gt;
  &lt;ol&gt;
    &lt;li id=&quot;fn:1&quot;&gt;
      &lt;p&gt;I sometimes data posion by mixing fake and legitimate PII, but I would never stoop so low to use the name Tim, so I wonder if a real Tim out there is using my phone number. Or a fake Tim using my phone number… &lt;a href=&quot;#fnref:1&quot; class=&quot;reversefootnote&quot; role=&quot;doc-backlink&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:2&quot;&gt;
      &lt;p&gt;Microsoft seem to do their best to bury this article when they change all their URLs every 5 years. This current URL says 2016 but it was published in 2012 (&lt;a href=&quot;https://web.archive.org/web/20120729003904/http://research.microsoft.com/apps/pubs/default.aspx?id=167719&quot;&gt;thank you internet archive&lt;/a&gt;). I’m slightly surprised that microsoft research are using wordpress now? &lt;a href=&quot;#fnref:2&quot; class=&quot;reversefootnote&quot; role=&quot;doc-backlink&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:3&quot;&gt;
      &lt;p&gt;Obviously all of this was possible before LLMs, but they make it easier, therefore more and more bigger. &lt;a href=&quot;#fnref:3&quot; class=&quot;reversefootnote&quot; role=&quot;doc-backlink&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:4&quot;&gt;
      &lt;p&gt;Hey, that’s the title of the post. Did you think of that line and try to come up with a post that vaguely suited it even if it was a bit of a stretch? Bah. &lt;a href=&quot;#fnref:4&quot; class=&quot;reversefootnote&quot; role=&quot;doc-backlink&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;
</description>
        <pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://chriswebsite.com///2026/04/22/the-only-defence-against-a-bad-guy-with-an-llm.html</link>
        <guid isPermaLink="true">https://chriswebsite.com///2026/04/22/the-only-defence-against-a-bad-guy-with-an-llm.html</guid>
      </item>
    
      <item>
        <title>Automatically updating the &apos;about&apos; page with my last.fm top listen</title>
        <description>&lt;p&gt;I “scrobble” all of my listens to &lt;a href=&quot;https://www.last.fm/&quot;&gt;last.fm&lt;/a&gt;, which for some reason still exists.
You can automatically link Spotify to last.fm to have all of your listens synced there — and for some reason this feature still exists.
This is quite nice, because Spotify have deigned that your stats are only available once a year —und exactly vonce a year— in a specially curated selection of statistics.
I thought it would be fun to put my top track of the month on the &lt;a href=&quot;/about&quot;&gt;about page&lt;/a&gt;, and this proved quite easy to automate with a github workflow.&lt;/p&gt;

&lt;h2 id=&quot;the-script&quot;&gt;The script&lt;/h2&gt;

&lt;p&gt;The python script is &lt;a href=&quot;https://github.com/christopherdoyle/christopherdoyle.github.io/blob/main/code/last-fm-sync/main.py&quot;&gt;here&lt;/a&gt;, it’s pretty dumb because I started writing something generalized and then got bored and specialised for this task.
You run it, it reads an API key and a username from the env, and it spits out the top track details into…&lt;/p&gt;

&lt;h2 id=&quot;_configyml&quot;&gt;_config.yml&lt;/h2&gt;

&lt;p&gt;Jekyll blog engine has a nice little YAML config file, and we can reference the variables within from any of the markdown pages.
In particular, the config can contain this:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;top_track&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;artist&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Dire Straits&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;On Every Street&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;playcount&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;33&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;about.md&lt;/code&gt; can contain this:&lt;/p&gt;

&lt;div class=&quot;language-markdown highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;*&lt;/span&gt; My most listened track this month is {{ site.top_track.name }} by {{ site.top_track.artist }} ({{ site.top_track.playcount }} listens)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It wouldn’t have been hard to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sed&lt;/code&gt; the about page directly, but this is much cleaner.
We can read the YAML file into a python object, update it, and write it back.
I didn’t bother making this stable, so it can create some unnecessary churn to the YAML file in the diff, but I don’t really care.
Once Jekyll build runs, the about page is updated with the new values of the variables.&lt;/p&gt;

&lt;h2 id=&quot;gh-workflow&quot;&gt;GH workflow&lt;/h2&gt;

&lt;p&gt;The workflow is straightforward, it just checks out the code, installs the python deps, run the script, then gits the changes into a branch and opens a PR.
I’d like to review the PR to start with and make sure it is sensible, and also nuke the PR when it has a truly embarrassing track, but it should be possible to automatically merge this, or otherwise push directly to main.
The only potential problem I see pushing to main is getting GH actions to be happy running the Jekyll build so that the site actually updates.&lt;/p&gt;
</description>
        <pubDate>Fri, 23 May 2025 00:00:00 +0000</pubDate>
        <link>https://chriswebsite.com///2025/05/23/Automating-last-fm-about-page.html</link>
        <guid isPermaLink="true">https://chriswebsite.com///2025/05/23/Automating-last-fm-about-page.html</guid>
      </item>
    
      <item>
        <title>Collecting Prometheus Metrics Across Docker Services While Maintaining Network Separation</title>
        <description>&lt;h2 id=&quot;problem-description&quot;&gt;Problem Description&lt;/h2&gt;

&lt;p&gt;In a Docker environment, we have multiple services running in separate networks.
However, we want to collect metrics from these services using a single Prometheus container, without exposing services to each other.
We can’t / don’t want to modify the services to expose their metrics to the Prometheus network (e.g. a push gateway, firewall rules, etc.).&lt;/p&gt;

&lt;p&gt;Suppose we have a docker compose:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;my_app&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;my_app&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;80:8080&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Now, if you want to collect metrics from this app using Prometheus, you can do so by adding a new service to the same compose file:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;my_app&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;my_app&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;80:8080&quot;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;prom&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;prometheus&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;9090:9090&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is fine, and we can access the app and Prometheus on their respective ports, but it doesn’t scale easily to many apps - we would end up with a prometheus instance for each app.
If we want to have a &lt;em&gt;single&lt;/em&gt; instance of Prometheus, a naive solution would be to have a single network for all the apps and Prometheus.
But this is not a good idea, as it would expose all the apps to each other.&lt;/p&gt;

&lt;h2 id=&quot;a-solution&quot;&gt;A Solution&lt;/h2&gt;

&lt;p&gt;One solution to this problem is to add a reverse proxy configuration to each service, which exposes &lt;em&gt;just&lt;/em&gt; the metrics endpoint to the Prometheus network.&lt;/p&gt;

&lt;h3 id=&quot;compose-prometheusyml&quot;&gt;compose-prometheus.yml&lt;/h3&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;prom&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;prometheus&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;9090:9090&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;networks&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;prometheus&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;networks&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;prometheus&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;prometheus&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;compose-my_appyml&quot;&gt;compose-my_app.yml&lt;/h3&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# compose-my_app.yml&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;my_app&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;my_app&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;80:8080&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;networks&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
     &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;internal&lt;/span&gt;

  &lt;span class=&quot;na&quot;&gt;prom_nginx&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;nginx:alpine&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;hostname&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;my_app_metrics&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;container_name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;my_app_metrics&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;networks&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;internal&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;prometheus&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;restart&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;always&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;configs&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;source&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;prom_nginx_config&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;/etc/nginx/nginx.conf&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;configs&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;prom_nginx_config&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;worker_processes 1;&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;events {&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;worker_connections 1024;&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;http {&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;server {&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;listen 80;&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;location /metrics {&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;proxy_pass http://my_app:8081/metrics;&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;}&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;location / {&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;return 403;&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;networks&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;internal&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;my_app_internal&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;prometheus&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;external&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Here, we introduce a new service, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prom_nginx&lt;/code&gt;, which is a reverse proxy for the metrics endpoint of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;my_app&lt;/code&gt;.
In this scenario, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;my_app&lt;/code&gt; exposes metrics on port 8081, and the main application on 8080.
As such, the metrics are only exposed to the Prometheus network and not to the end users of the app.
Moreover, Prometheus has access only to the metrics endpoint, and not to the main application.
Nginx is a bridge between the two networks, exposing just that metrics endpoint (and nothing else).
This can easily scale to many applications, each with their own Nginx bridge.&lt;/p&gt;

&lt;p&gt;The Nginx container is configured using the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;configs&lt;/code&gt; directive, which avoids the need to create and mount an Nginx config file for each service’s compose file - the nginx container and config block can be copy-pasted for each service, with minor edits.&lt;/p&gt;

&lt;h3 id=&quot;prometheusyml&quot;&gt;prometheus.yml&lt;/h3&gt;

&lt;p&gt;The prometheus config will look like:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# prometheus.yml&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;scrape_configs&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;job_name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;my_app&apos;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;static_configs&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;targets&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;my_app_metrics:80&apos;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;job_name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;my_app2&apos;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;static_configs&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;targets&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;my_app2_metrics:80&apos;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
</description>
        <pubDate>Mon, 21 Apr 2025 00:00:00 +0000</pubDate>
        <link>https://chriswebsite.com///2025/04/21/collecting-prometheus-metrics.html</link>
        <guid isPermaLink="true">https://chriswebsite.com///2025/04/21/collecting-prometheus-metrics.html</guid>
      </item>
    
      <item>
        <title>Self-hosted backups with autorestic, ntfy, and B2</title>
        <description>&lt;h2 id=&quot;overview&quot;&gt;Overview&lt;/h2&gt;

&lt;p&gt;We will use &lt;a href=&quot;https://autorestic.vercel.app/&quot;&gt;autorestic&lt;/a&gt;, a wrapper for &lt;a href=&quot;https://restic.net/&quot;&gt;restic&lt;/a&gt;, to backup data to &lt;a href=&quot;https://www.backblaze.com/cloud-storage&quot;&gt;Backblaze B2&lt;/a&gt;; cron to automate the backups; and self-hosted &lt;a href=&quot;https://ntfy.sh/&quot;&gt;ntfy.sh&lt;/a&gt; for backup push notifications.
We will backup the directory &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/apps&lt;/code&gt; on our server.
This assumes you are already running a publicly accessible server with a domain name &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;domain.com&lt;/code&gt;, and you have docker, nginx, and certbot setup.&lt;/p&gt;

&lt;h2 id=&quot;b2&quot;&gt;B2&lt;/h2&gt;

&lt;p&gt;Create a container in B2, or use an existing container, and create an API key with read-write access to that container.
The backups will be encrypted, but the B2 container should be set to private access only.
You can enable encryption at host in the B2 container, this would add an extra layer of encryption, but it is not necessary.&lt;/p&gt;

&lt;p&gt;Create a new API key for the container, with access to just that container.&lt;/p&gt;

&lt;h2 id=&quot;setting-up-autorestic&quot;&gt;Setting up autorestic&lt;/h2&gt;

&lt;p&gt;autorestic is a couple of binaries and a config file.
Run the install command on &lt;a href=&quot;https://autorestic.vercel.app/quick&quot;&gt;autorestic’s quick start&lt;/a&gt; page on the server to install.
Now create a config file like this in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/apps/.autorestic.yml&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;2&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;locations&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;apps&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;/apps&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;remote&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;cron&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*/6&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&apos;&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# At 5 minutes past the hour, every 6 hours&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;backends&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;remote&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;b2&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;b2-container-name:/path/in/container&apos;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;B2_ACCOUNT_ID&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;...&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;B2_ACCOUNT_KEY&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;...&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;...&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;requirekey&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Fill in the B2 API ID and Key.
The “key” field is your encryption password for the backups.
This is the absolute minimum that you need to store from this configuration file in order to restore the backup.
Store the key (or the entire config file) in a password manager, or write it down.
You can also store the key in a separate file (and your password manager), and the B2 key in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env&lt;/code&gt; file, which keeps the compose file secret-free, allowing you to store it in a public/private repo as a backup.&lt;/p&gt;

&lt;p&gt;At this point, autorestic should work.
Run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autorestic check&lt;/code&gt; to check the config.
Then run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autorestic backup -a&lt;/code&gt; to initiate the first full backup.&lt;/p&gt;

&lt;h2 id=&quot;ntfy-initial-setup&quot;&gt;ntfy initial setup&lt;/h2&gt;

&lt;p&gt;ntfy is an open-source notification pub-sub server with clients for the browser, Android, and iOS.
The iOS app is not great and the configuration can be a little finicky.
We will self-host ntfy in docker, but you can also use their free tier at ntfy.sh up to some limits.&lt;/p&gt;

&lt;p&gt;Create a CNAME record &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ntfy&lt;/code&gt; targetting the domain root (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;domain.com&lt;/code&gt;).
This will host the web app and the pub-sub service.&lt;/p&gt;

&lt;p&gt;Now:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nb&quot;&gt;mkdir&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; /apps/ntfy/&lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;config,data&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;cd&lt;/span&gt; /apps/ntfy
wget https://raw.githubusercontent.com/binwiederhier/ntfy/main/server/server.yml &lt;span class=&quot;nt&quot;&gt;-O&lt;/span&gt; config/server.yml
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Create a compose file &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/apps/ntfy/compose.yaml&lt;/code&gt; with the following content:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;ntfy&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;binwiederhier/ntfy&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;container_name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;ntfy&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;command&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;serve&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;TZ=UTC&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;volumes&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;./config:/etc/ntfy&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;./data:/var/lib/ntfy&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;ports&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;8000:80&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;healthcheck&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;# optional: remember to adapt the host:port to your environment&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;test&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;CMD-SHELL&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;wget&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-q&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;--tries=1&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;http://localhost:80/v1/health&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-O&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;grep&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-Eo&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;healthy&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&quot;\\&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;s*:&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\\&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;s*true&apos;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;||&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;exit&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;1&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;interval&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;60s&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;10s&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;retries&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;3&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;start_period&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;40s&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;restart&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Set 8000 to some free port on the host.&lt;/p&gt;

&lt;p&gt;Now edit the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;server.yml&lt;/code&gt; file and set at least the following fields:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;base-url&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;https://ntfy.domain.com/&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;listen-http&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;80&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;cache-file&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;/var/lib/ntfy/cache.db&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;auth-file&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;/var/lib/ntfy/auth.db&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;auto-default-access&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;deny-all&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;behind-proxy&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;attachment-cache-dir&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;/var/lib/ntfy/attachments&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;enable-signup&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;enable-login&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;upstream-base-url&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;https://ntfy.sh&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You can now run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker compose up -d&lt;/code&gt; to start the server.
It should start successfully, but it won’t be accessible until we set up nginx.&lt;/p&gt;

&lt;h2 id=&quot;nginx&quot;&gt;nginx&lt;/h2&gt;

&lt;p&gt;Create a file &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/etc/nginx/sites-available/ntfy&lt;/code&gt; with content:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;s&quot;&gt;server {&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;server_name ntfy.domain.com&lt;/span&gt;

  &lt;span class=&quot;s&quot;&gt;location / {&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;proxy_set_header   X-Real-IP $remote_addr;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;proxy_set_header   Host $host;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;proxy_pass         http://localhost:8000/;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;proxy_http_version 1.1;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;proxy_set_header   Upgrade $http_upgrade;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;proxy_set_header   Connection &quot;upgrade&quot;;&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;err&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;8000 should be the same port as in the docker compose file.&lt;/p&gt;

&lt;p&gt;Enable the site with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ln -s /etc/nginx/sites-available/ntfy /etc/nginx/sites-enabled&lt;/code&gt;.
Then use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;certbot&lt;/code&gt; to generate certificates and update the config file.
Finally run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nginx -t&lt;/code&gt; to check the config, before &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemctl reload nginx&lt;/code&gt; to reload the config.&lt;/p&gt;

&lt;p&gt;At this point, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://ntfy.domain.com&lt;/code&gt; should be available.
The website is really just a local web app.
You’ll notice the config was set to “deny-all” and “require login”, but the web app is fully accessible without any login at all.
This is totally fine, you’ll find that if you try to publish and subscribe to anything, it will prompt for a login.&lt;/p&gt;

&lt;h2 id=&quot;ntfy-users&quot;&gt;ntfy users&lt;/h2&gt;

&lt;p&gt;For the logins, we should set up users as required.
We will create one user for autorestic, and one user for you, “bob”.
Since we are running ntfy in docker, we will use docker exec to open a shell and run the commands there.
You can also run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker exec ntfy ntfy&lt;/code&gt; to invoke &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ntfy&lt;/code&gt; directly.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;docker &lt;span class=&quot;nb&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-it&lt;/span&gt; ntfy /bin/sh
ntfy user add &lt;span class=&quot;nt&quot;&gt;--role&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;user bob
ntfy access bob autorestic read-only
ntfy user add &lt;span class=&quot;nt&quot;&gt;--role&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;user autorestic
ntfy access autorestic autorestic write-only
ntfy token add autorestic
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You could also just set up one user, even a user with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--role=admin&lt;/code&gt;, and use that for everything.
These are specific permissions, giving bob read access to the autorestic topic; and autorestic user write access.
The last command creates a token, save the value, we will use it later.&lt;/p&gt;

&lt;h2 id=&quot;ntfy-push&quot;&gt;ntfy push&lt;/h2&gt;

&lt;p&gt;Lastly, we want to set up ntfy to be able to push in browser.
Run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ntfy webpush keys&lt;/code&gt; to generate a config, and copy it.
Exit the docker exec and edit the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;server.yml&lt;/code&gt; file, and paste the fields in.
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;web-push-email-address&lt;/code&gt; must be set, e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;admin@domain.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now restart ntfy with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker compose restart&lt;/code&gt;.
You should be able to enable ntfy notifications in the browser now, there is a banner at the top-left prompting you to enable them when you access the web app.&lt;/p&gt;

&lt;h2 id=&quot;ntfy-ios&quot;&gt;ntfy iOS&lt;/h2&gt;

&lt;p&gt;You can subscribe to the ‘autorestic’ channel in the web apps, or the android app, easily.&lt;/p&gt;

&lt;p&gt;iOS is a bit weird.
Download the &lt;a href=&quot;https://apps.apple.com/us/app/ntfy/id1625396347&quot;&gt;iOS&lt;/a&gt; app and install it.
Then go the the global settings and set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://ntfy.domain.com&lt;/code&gt; as the default server.
Then subscribe to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autorestic&lt;/code&gt; channel, you will need to enter your username (“bob”) and password.&lt;/p&gt;

&lt;p&gt;I found that any other order of operations did not work.
Setting the server when subscribing to the channel was not sufficient, and setting the global setting after subscribing did not work either!
Notifications would only appear when manually refreshing, and not get pushed out.&lt;/p&gt;

&lt;h2 id=&quot;set-up-ntfy-in-autorestic&quot;&gt;set up ntfy in autorestic&lt;/h2&gt;

&lt;p&gt;Go back to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.autorestic.yml&lt;/code&gt; and edit the location like so:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;locations&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;apps&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;/apps&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;remote&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;cron&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*/6&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&apos;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;hooks&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;success&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;curl&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-H&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Authorization:&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Bearer&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;tk_XXXX&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-H&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Title:&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Backup&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;successful&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-H&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Tags:&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;+1&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-d&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;https://ntfy.domain.com/autorestic&apos;&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;failure&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;curl&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-H&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Authorization:&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Bearer&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;tk_XXXX&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-H&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Title:&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Backup&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;failure&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-H&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Tags:&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;rotating_light&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-H&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Priority:&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;high&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-d&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;https://ntfy.domain.com/autorestic&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tf_XXXX&lt;/code&gt; is the token created earlier for the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autorestic&lt;/code&gt; user.
I needed quotes around the entire curl line otherwise YAML complained, not sure why.
Run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autorestic check&lt;/code&gt; to check the config.&lt;/p&gt;

&lt;p&gt;You can, and probably should, use the curl command directly to test the notification.
There are more headers and settings in the documentation if you like.
When you run the curl command, you should get a successful response back, and a notification should appear on your phone/web.&lt;/p&gt;

&lt;h2 id=&quot;crontab&quot;&gt;crontab&lt;/h2&gt;

&lt;p&gt;The cron settings in the autorestic YAML and for its internal timekeeping.
This is the frequency you want to backup the location at.
You can have multiple locations, each with their own schedule.&lt;/p&gt;

&lt;p&gt;A crontab job should be set up to invoke the cron frontend of autorestic, for example:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;*/5 * * * * autorestic -c /apps/.autorestic.yml --ci cron
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is running every 5 minutes, but this just means we’re asking autorestic to check the schedules every five minutes.&lt;/p&gt;

&lt;p&gt;After autorestic has been run once, it will create a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/apps/.autorestic.lock.yml&lt;/code&gt; file.
This is a miniature database that stores the unix timestamp of the last run, and is used to track the cron jobs.&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;You now have fully automated backups of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/apps&lt;/code&gt; directory to B2.
The backups are encrypted and incremental, and you receive notifications on successful and failed jobs pushed to your phone, all on self-hosted and open source infrastructure.&lt;/p&gt;

&lt;h2 id=&quot;but-wait-backups-do-not-exist-until-they-have-been-tested&quot;&gt;But wait. Backups do not exist until they have been tested.&lt;/h2&gt;

&lt;p&gt;Yeah you should really check they work.
Install &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autorestic&lt;/code&gt; on another server or computer, and copy the contents of the YML config file into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.autorestic.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;autorestic restore -l apps --from remote --to apps&lt;/code&gt;.
This will initiate the restore process into an apps directory (must be empty).
Note that our &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/apps&lt;/code&gt; directory includes ntfy, so you could also run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker compose up -d apps/ntfy&lt;/code&gt; as a final check.&lt;/p&gt;
</description>
        <pubDate>Sun, 01 Sep 2024 00:00:00 +0000</pubDate>
        <link>https://chriswebsite.com///2024/09/01/automated-backups.html</link>
        <guid isPermaLink="true">https://chriswebsite.com///2024/09/01/automated-backups.html</guid>
      </item>
    
      <item>
        <title>LaTeX GitHub Workflow with Release Tagging</title>
        <description>
&lt;p&gt;&lt;span class=&quot;caps&quot;&gt;Create a GitHub workflow&lt;/span&gt; for compiling 
&lt;span class=&quot;latex&quot;&gt;L&lt;sup&gt;A&lt;/sup&gt;T&lt;sub&gt;E&lt;/sub&gt;X&lt;/span&gt;
 to PDF including version tagging on the commit and inside the PDF, and asset release.
See &lt;a href=&quot;https://github.com/christopherdoyle/LaTexTemplate&quot;&gt;christopherdoyle:LaTeXTemplate&lt;/a&gt; for a basic working example.
The end result is:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;User merges PR into main.&lt;/li&gt;
  &lt;li&gt;Workflow pushes a new commit bumping the verison.&lt;/li&gt;
  &lt;li&gt;Workflow pushes a tag and release containing a compiled PDF, which includes the new version on the title page.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;the-code&quot;&gt;The Code&lt;/h2&gt;

&lt;p&gt;Here is the YAML workflow:&lt;/p&gt;

&lt;figure class=&quot;highlight&quot;&gt;&lt;pre&gt;&lt;code class=&quot;language-yaml&quot; data-lang=&quot;yaml&quot;&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Build LaTeX document&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;pull_request&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;closed&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;branches&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;jobs&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;build_latex&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;runs-on&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class=&quot;na&quot;&gt;permissions&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;write&lt;/span&gt;

    &lt;span class=&quot;na&quot;&gt;steps&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;

      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Set up Git repository&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;ref&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;${{ github.ref_name }}&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;fetch-depth&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;0&apos;&lt;/span&gt;

      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Get next version&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;get_next_version&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;thenativeweb/get-next-version@main&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;prefix&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;v&apos;&lt;/span&gt;

      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Show the next version&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;echo ${{ steps.get_next_version.outputs.version }}&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;echo ${{ steps.get_next_version.outputs.hasNextVersion }}&lt;/span&gt;

      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Update version.tex, tag, commit, push&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;steps.get_next_version.outputs.hasNextVersion == &apos;true&apos;&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;NEW_VERSION&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;${{ steps.get_next_version.outputs.version }}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;shell&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;bash&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;old_version=$(git describe --tags --abbrev=0)&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;sed -i &quot;s/$old_version/$NEW_VERSION/&quot; version.tex&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;git config user.name &quot;$(git log -n 1 --pretty=format:%an)&quot;&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;git config user.email &quot;$(git log -n 1 --pretty=format:%ae)&quot;&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;git add version.tex&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;git commit -m &quot;Bump version $old_version-&amp;gt;${{ steps.get_next_version.outputs.version }}&quot;&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;git push&lt;/span&gt;

      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Compile LaTeX document&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;xu-cheng/latex-action@3.1.0&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;steps.get_next_version.outputs.hasNextVersion == &apos;true&apos;&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;root_file&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;main.tex&lt;/span&gt;

      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Create Release&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;create_release&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;softprops/action-gh-release@v1&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;steps.get_next_version.outputs.hasNextVersion == &apos;true&apos;&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Release ${{ steps.get_next_version.outputs.version }}&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;tag_name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;${{ steps.get_next_version.outputs.version }}&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;draft&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;false&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;prerelease&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;false&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;main.pdf&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;

&lt;h2 id=&quot;how-it-work&quot;&gt;How it work&lt;/h2&gt;

&lt;p&gt;We use the &lt;a href=&quot;https://github.com/marketplace/actions/get-next-version&quot;&gt;get next version action&lt;/a&gt; to interpret commit messages into major-minor-patch version bumps.
This is detailed on the linked page, but for example a commit message like “fix: typos” will lead to a &lt;em&gt;patch&lt;/em&gt; bump, say from 1.2.5 to 1.2.6.&lt;/p&gt;

&lt;p&gt;To store the version inside the compiled document, we touch a small file called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.tex&lt;/code&gt; which is being &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;\input&lt;/code&gt; to the main 
&lt;span class=&quot;latex&quot;&gt;L&lt;sup&gt;A&lt;/sup&gt;T&lt;sub&gt;E&lt;/sub&gt;X&lt;/span&gt;
 document.
We need to have at least one tag already in git history for this to work, because we are reading the old version from the tag history (not from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.tex&lt;/code&gt; directly).
So when setting up the workflow, you need to push a tag first.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.tex&lt;/code&gt; looks like:&lt;/p&gt;

&lt;figure class=&quot;highlight&quot;&gt;&lt;pre&gt;&lt;code class=&quot;language-latex&quot; data-lang=&quot;latex&quot;&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;k&quot;&gt;\newcommand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;\Version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}{}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;\makeatletter&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;\renewcommand&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;\maketitle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}{&lt;/span&gt;&lt;span class=&quot;c&quot;&gt;%&lt;/span&gt;
    &lt;span class=&quot;c&quot;&gt;%...&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;\Version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{}&lt;/span&gt;
    &lt;span class=&quot;c&quot;&gt;%...&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;\makeatother&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;\input&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;version.tex&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;\begin{document}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;\maketitle&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;\end{document}&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;

&lt;p&gt;and the contents of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version.tex&lt;/code&gt; is like:&lt;/p&gt;

&lt;div class=&quot;language-latex highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;\renewcommand\Version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;v1.2.5&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;We use a custom &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;\maketitle&lt;/code&gt; to add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;\Version&lt;/code&gt; to the title page.
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;\Version&lt;/code&gt; could be defined and sed directly in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.tex&lt;/code&gt;, but it is safer to have a minimal separate file for this.&lt;/p&gt;

&lt;p&gt;After sed-ing the version, the changes are committed with the prior commit’s author meta, and pushed to the main branch.
So after this runs we have an extra commit:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;feat: added formula for three-body problem&lt;/li&gt;
  &lt;li&gt;Bump version v1.2.5 -&amp;gt; 1.3.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first commit is you merging a PR; the second commit is from the workflow.
The new commit is not tagged yet.
First we build the PDF with &lt;a href=&quot;https://github.com/xu-cheng/latex-action&quot;&gt;latex-action&lt;/a&gt;, then use &lt;a href=&quot;https://github.com/softprops/action-gh-release&quot;&gt;action-gh-release&lt;/a&gt; to push a tagged release that contains the PDF.&lt;/p&gt;

&lt;h2 id=&quot;bonus-distribution-to-nextcloud-dropboxetc&quot;&gt;Bonus: distribution to NextCloud (/Dropbox/etc.)&lt;/h2&gt;

&lt;p&gt;If you want to give your supervisor the latest version without adding them to your repository, you could use something like NextCloud WebDAV to push to a hosted NextCloud instance:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Upload Release Asset to NextCloud&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;env.DO_BUMP_RELEASE == &apos;true&apos;&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;curl&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-u&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;${{&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;secrets.NEXTCLOUD_USERNAME&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;}}:${{&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;secrets.NEXTCLOUD_PASSWORD&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;}}&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;-T&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;./main.pdf&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;${{&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;secrets.NEXTCLOUD_URL&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;}}&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This requires you to set the secrets in your repository’s &lt;a href=&quot;https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions&quot;&gt;secrets and variables&lt;/a&gt; page.&lt;/p&gt;
</description>
        <pubDate>Wed, 21 Feb 2024 00:00:00 +0000</pubDate>
        <link>https://chriswebsite.com///2024/02/21/LaTeX-GitHub-Workflow-with-Release-Tagging.html</link>
        <guid isPermaLink="true">https://chriswebsite.com///2024/02/21/LaTeX-GitHub-Workflow-with-Release-Tagging.html</guid>
      </item>
    
      <item>
        <title>SQL: finding latest records within groups</title>
        <description>&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;/h2&gt;

&lt;p&gt;A common task in databases is to find the latest X within each group Y.
Let’s take the &lt;a href=&quot;https://www.brentozar.com/archive/2015/10/how-to-download-the-stack-overflow-database-via-bittorrent/&quot;&gt;StackOverflow2010&lt;/a&gt; database,
and try o find the latest posts for each user.
The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Posts&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Users&lt;/code&gt; tables look like:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TOP&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DisplayName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;table class=&quot;big-width&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;DisplayName&lt;/th&gt;
      &lt;th&gt;Title&lt;/th&gt;
      &lt;th&gt;CreationDate&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Eggs McLaren&lt;/td&gt;
      &lt;td&gt;Convert Decimal to Double?&lt;/td&gt;
      &lt;td&gt;2008-07-31 21:42:52.667&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Kevin Dente&lt;/td&gt;
      &lt;td&gt;Percentage width child element in absolutely positioned parent on Internet Explorer 7&lt;/td&gt;
      &lt;td&gt;2008-07-31 22:08:08.620&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Kevin Dente&lt;/td&gt;
      &lt;td&gt;NULL&lt;/td&gt;
      &lt;td&gt;2008-07-31 22:17:57.883&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeff Atwood&lt;/td&gt;
      &lt;td&gt;“How do I calculate someone’s age in C#?”&lt;/td&gt;
      &lt;td&gt;2008-07-31 23:40:59.743&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Jeff Atwood&lt;/td&gt;
      &lt;td&gt;Calculate relative time in C#&lt;/td&gt;
      &lt;td&gt;2008-07-31 23:55:37.967&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;We can find the latest post for a user like this:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TOP&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DisplayName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;?&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;But what if we want the latest post for every user?
If we take away the where clause, we still only get one row; a different tactic is needed.
&lt;span class=&quot;marginnote&quot;&gt;Actually, there is a way to use TOP 1 like this, more on that later.&lt;/span&gt;&lt;/p&gt;

&lt;h2 id=&quot;cte-and-max&quot;&gt;CTE and MAX&lt;/h2&gt;

&lt;p&gt;A common solution is to use a subquery or CTE to find the MAX, then join back onto &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Posts&lt;/code&gt; like this:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;MaxCreationDate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;MAX&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;GROUP&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DisplayName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MaxCreationDate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This will work, but joining on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CreationDate&lt;/code&gt; is not ideal as there could be two rows with the same creation date for a user — unlikely in this dataset, but true in general.
We can ue &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Id&lt;/code&gt; instead, but then we are relying on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Id&lt;/code&gt; increasing directly with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CreationDate&lt;/code&gt;.
Depending on the dataset this might not be possible.&lt;/p&gt;

&lt;p&gt;It’s also quite slow. This query takes about 20 seconds on my laptop.
A big part of the reason for the slowness is the self-join on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Posts&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;temp-table&quot;&gt;Temp table&lt;/h2&gt;

&lt;p&gt;Self-joins like this are generally black holes of performance for SQL Server.
It doesn’t always know how to optimize the query well, it’s not obvious what you are doing.
In particular it won’t realize that the CTE is quite small, and that it could evaluate it first on it’s own.
We can force it to do this by putting the results into a temp table:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;MaxCreationDate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;MAX&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INTO&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LatestPost&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;GROUP&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DisplayName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LatestPost&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MaxCreationDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This takes even longer at 24 seconds!
Unfortunately, in this case, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#LatestPost&lt;/code&gt; is rather large and takes 10 seconds to populate on its own.
In other examples, this solution can net a quick and easy performance benefit.&lt;/p&gt;

&lt;h2 id=&quot;windowing-functions&quot;&gt;Windowing Functions&lt;/h2&gt;

&lt;p&gt;Now we use &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/functions/row-number-transact-sql?view=sql-server-ver16&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ROW_NUMBER&lt;/code&gt;&lt;/a&gt;.
This keeps track of the row number within each group that we specify, in some order that we specify.
Since we order by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CreationDate DESC&lt;/code&gt;, if we look at just &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RN = 1&lt;/code&gt;, then we have the latest occurrences.
This also avoids draws, though the draw winner is arbitrary and non-deterministic.
We could order by another (unique) column as well (such as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Id&lt;/code&gt;) to solve this.&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;RN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ROW_NUMBER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OVER&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;PARTITION&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DisplayName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CTE_LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;RN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This takes only 12 seconds.&lt;/p&gt;

&lt;p&gt;The advantage of this query is that we don’t need a self-join.
In the first query, we are joining &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Posts&lt;/code&gt; onto &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Posts&lt;/code&gt;.
Here we can select all the columns from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Posts&lt;/code&gt; that we need inside the CTE, so there is no need to join back.&lt;/p&gt;

&lt;h2 id=&quot;with-ties--windowing-function&quot;&gt;With Ties + Windowing Function&lt;/h2&gt;

&lt;p&gt;Perhaps you feel needing a CTE/subquery is still a bit cumbersome.
Here is a solution in a single query that uses the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ROW_NUMBER&lt;/code&gt; partitioning with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOP 1 WITH TIES&lt;/code&gt; to give us the latest within each group.&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TOP&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TIES&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DisplayName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ROW_NUMBER&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OVER&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;PARTITION&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOP 1 WITH TIES&lt;/code&gt; gives us the latest from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ORDER BY&lt;/code&gt;, and if there are ties for first place then it gives us all of them.
Our &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ORDER BY&lt;/code&gt; is designed to give ties, it will rank the latest post for each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OwnerUserId&lt;/code&gt; as joint first in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ORDER BY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a slightly slower solution at about 14 seconds.&lt;/p&gt;

&lt;h3 id=&quot;adding-an-index&quot;&gt;Adding an index&lt;/h3&gt;

&lt;p&gt;Until now, the only index on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Posts&lt;/code&gt; was a unique index on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Id&lt;/code&gt;, which we are not using.&lt;/p&gt;

&lt;p&gt;Let’s create an index for our workload:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;NONCLUSTERED&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;IX_Posts_CreationDate&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;INCLUDE&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Rerunning the timing tests we get:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Naive: 7.4 seconds&lt;/li&gt;
  &lt;li&gt;Temp table: 3.1 seconds&lt;/li&gt;
  &lt;li&gt;Windowing: 3.3 seconds&lt;/li&gt;
  &lt;li&gt;With Ties: 4.6 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;looking-closer-io-usage&quot;&gt;Looking closer: IO usage&lt;/h2&gt;

&lt;p&gt;The temp table solution is slightly faster than our windowing solution.
Now let’s compare their IO usage using &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/statements/set-statistics-io-transact-sql?view=sql-server-ver16&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SET STATISTICS TIME&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Table &apos;Posts&apos;. Scan count 6, logical reads 24809, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table &apos;Users&apos;. Scan count 0, logical reads 785856, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table &apos;Posts&apos;. Scan count 1, logical reads 24547, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table &apos;#LatestPost_________________________________________________________________________________________________________000000000004&apos;. Scan count 1, logical reads 681, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Table &apos;Posts&apos;. Scan count 6, logical reads 24781, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table &apos;Users&apos;. Scan count 6, logical reads 7769, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;So while the temp table solution is quicker, it’s hiting the tables significantly harder.
On the other hand, the CPU times (measure of CPU load) of the temp table is much lower than the windowing function.
Overall this is a tradeoff between CPU and IO.
Which method is faster will depend on the particular workflow and hardware.
Which method is &lt;em&gt;best&lt;/em&gt; will depend on what else you have running on these tables.
If you are already IO constrained, it might be preferable to take a slightly slower overall query that has much lower CPU load.&lt;/p&gt;

&lt;h2 id=&quot;further-refinement-temp-table-indexes&quot;&gt;Further refinement: temp table indexes&lt;/h2&gt;

&lt;p&gt;We can get another small benefit (about 2.5 second total time) by adding a primary key to our temp table:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LatestPost&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;PRIMARY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;KEY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;MaxCreationDate&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;datetime&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;INSERT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INTO&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LatestPost&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MaxCreationDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;MaxCreationDate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;MAX&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;GROUP&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DisplayName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Users&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Id&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;INNER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LatestPost&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OwnerUserId&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CreationDate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;#&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LatestPost&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MaxCreationDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;We could also add an index on MaxCreationDate but this probably won’t have a huge impact because of the time required to build it.
Sometimes the cost of adding a primary key is also not worth it, again it depends on workload.&lt;/p&gt;

&lt;p&gt;This leads to my final thought on optimizing this solution: engineering.
Compared to other solutions, we now have a solution which is 3 statements (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CREATE&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;INSERT&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT&lt;/code&gt;) compared to 1 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT&lt;/code&gt;).
It is also a bit harder to maintain: we need to keep the temp table schema in sync with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Posts&lt;/code&gt; table, in other words our solution is more tightly coupled to the source tables.
The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WITH TIES&lt;/code&gt; solution is slick and concise, but how many people on your team will understand it?
These things might not matter in a toy problem, but if this strategy is used extensively, it can become burdonsome to maintain.
It can also lead to errors: if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CreationDate&lt;/code&gt; is changed from a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;datetime&lt;/code&gt; to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;date&lt;/code&gt; (it is called a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Date&lt;/code&gt;, but this &lt;em&gt;is&lt;/em&gt; encoding a time; in other datasets, particularly older ones, it is not uncommon to see pure dates in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;datetime&lt;/code&gt; column), and the temp table schema is not updated, then you are know adding a small cast to the insert, and another cast to the join in the select.
Sometimes this penalty is imperceptible, but it could have a bigger impact then the savings of this solution.
The best solution is not always the one that is quickest right now, and sometimes a slightly worse solution is better in the long run.&lt;/p&gt;
</description>
        <pubDate>Sat, 17 Feb 2024 00:00:00 +0000</pubDate>
        <link>https://chriswebsite.com///2024/02/17/SQL-finding-latest-records-within-groups.html</link>
        <guid isPermaLink="true">https://chriswebsite.com///2024/02/17/SQL-finding-latest-records-within-groups.html</guid>
      </item>
    
      <item>
        <title>TP-Link POE Switch Fan Replacement</title>
        <description>&lt;style&gt;
article h1 {
  color: #653024;
  text-shadow: 5px 5px 0 #e6ceb5;
  transition: all linear 500ms;
}
article h1:hover {
  cursor: pointer;
  text-shadow: -5px -5px 0 #e6ceb5;
  transition: text-shadow linear 500ms;
}

@media (prefers-color-scheme: dark) {
    article h1 {
      color: #e6ceb5;
      text-shadow: 5px 5px 0 #653024;
    }
    article h1:hover {
        text-shadow: -5px -5px 0 #653024;
    }
}
&lt;/style&gt;

&lt;p&gt;The &lt;a href=&quot;https://www.tp-link.com/uk/business-networking/omada-sdn-switch/tl-sg2210mp/&quot;&gt;TP-Link TL-SG2210MP&lt;/a&gt;
is a relatively inexpensive 10-Port Gigabit POE+ Switch, which I picked up for
about 177GBP.
It’s not really designed for home use, and the fan is…horrible.
It’s loud, but more importantly incredibly whiny.&lt;/p&gt;

&lt;p&gt;Luckily, I own a screwdriver.&lt;span class=&quot;marginnote&quot;&gt;There is a large exposed
power supply unit at the other side of the device to the fan, likely don’t touch this?&lt;/span&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/2023-04-18_switch-original-insides.jpg&quot; alt=&quot;TP-Link switch insides&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The fan inside my unit is a &lt;a href=&quot;http://www.sunon-fan.com/product/MF40201V3-1000C-F99.html&quot;&gt;SUNON
MF40201V3-1000C-F99&lt;/a&gt;.
According to the linked product page it pushes 6.3 CFM at a static pressure of
0.11in h2o.
A replacement fan would ideally hit these specs with a lower noise level.&lt;/p&gt;

&lt;p&gt;I replaced the fan with a &lt;a href=&quot;https://noctua.at/en/nf-a4x20-flx/specification&quot;&gt;Noctua NF-A4x20
FLX&lt;/a&gt; which pushes 9.4 m3/h,
about 5.5 CFM, at a static pressure of 2.26 mm h2o, about 0.09 in h2o.
This is a little lower than the SUNON, but I’m bargaining that the provided fan
is designed to handle a workload of up to 8 POE devices drawing a total of
150W, of which I will use a fraction.&lt;/p&gt;

&lt;p&gt;Replacing the fan is straightforward, except that they glued the fan header in.
Usually this is so that it does not come loose in shipping, but usually it is
easy to remove hot-glue gun glue, whereas this is more like PVA?&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/2023-04-18_glue.jpg&quot; alt=&quot;GLUE&quot; title=&quot;Remove carefully!&quot; /&gt;&lt;/p&gt;

&lt;p&gt;I carefully scraped off the glue with a screwdriver and replaced in the Noctua
fan. The fan is the same depth and the existing screws worked fine.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/2023-04-18_noctua.jpg&quot; alt=&quot;Noctua&quot; title=&quot;Sexy sexy brown and tan&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;fan-noise&quot;&gt;Fan noise&lt;/h2&gt;

&lt;p&gt;As a rough measure, I used a free smartphone app:&lt;/p&gt;

&lt;p&gt;&lt;span class=&quot;marginnote&quot;&gt;The switch has a loud full RPM spin up when it is
first started up. These numbers are &lt;em&gt;after&lt;/em&gt; that.&lt;/span&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;Noise (dB)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Base noise level&lt;/td&gt;
      &lt;td&gt;36&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Stock fan&lt;/td&gt;
      &lt;td&gt;49&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Noctua fan&lt;/td&gt;
      &lt;td&gt;38&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;This is great!
Subjectively, the Noctua fan is barely audible, and has no high-pitched whine.&lt;/p&gt;

&lt;h2 id=&quot;temperatures&quot;&gt;Temperatures&lt;/h2&gt;

&lt;p&gt;There are no temperature sensors on the TP-Link, at least none that are exposed
in the web interface or SSH interface.
It was cool to the touch before the replacement, and it is cool to the touch
now.
Given the low load I am applying, this is expected.&lt;/p&gt;
</description>
        <pubDate>Tue, 18 Apr 2023 00:00:00 +0000</pubDate>
        <link>https://chriswebsite.com///2023/04/18/tp-link-fan-replacement.html</link>
        <guid isPermaLink="true">https://chriswebsite.com///2023/04/18/tp-link-fan-replacement.html</guid>
      </item>
    
      <item>
        <title>Recipe for Brownies</title>
        <description>&lt;h2 id=&quot;ingredients&quot;&gt;Ingredients&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;100 g unsalted butter&lt;/li&gt;
  &lt;li&gt;175 g caster sugar&lt;/li&gt;
  &lt;li&gt;75 g dark muscovado sugar&lt;/li&gt;
  &lt;li&gt;150+ g dark chocolate&lt;/li&gt;
  &lt;li&gt;1 tbsp golden syrup&lt;/li&gt;
  &lt;li&gt;2 eggs&lt;/li&gt;
  &lt;li&gt;1 tsp vanilla extract (optional)&lt;/li&gt;
  &lt;li&gt;100 g plain flour&lt;/li&gt;
  &lt;li&gt;2 tbsp cocoa powder&lt;/li&gt;
  &lt;li&gt;1/2 tsp salt&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;method&quot;&gt;Method&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;Gently melt butter with most of the chocolate (100–125 g) and the
syrup in a double boiler.&lt;span class=&quot;marginnote&quot;&gt;Melting directly in a pan should work but this can split sometimes.&lt;/span&gt;
Once melted allow to cool to room temperature.
Chop the remainder of the chocolate into small pieces.&lt;/li&gt;
  &lt;li&gt;Grease a 20 cm (8 in) shallow square cake tin and line the base.
Preheat an oven to 170 °C.&lt;/li&gt;
  &lt;li&gt;Sift the flour, cocoa powder, and baking powder into a bowl with
a pinch of salt.&lt;/li&gt;
  &lt;li&gt;Once the butter and chocolate is mostly cooled, start beating together the
eggs, flavouring, and sugar with a whisk. This should take 5–10 minutes, we
want to incorporate some air.
&lt;span class=&quot;marginnote&quot;&gt;The darker sugar adds moisture and richness to the cake.&lt;/span&gt;&lt;/li&gt;
  &lt;li&gt;Fold the cooled chocolate mixture into the egg mixture, then sift
in the flour mixture. Stopping mixing when no streaks of flour
&lt;span class=&quot;marginnote&quot;&gt;Double sifting the flour reduces the change of clumps without overmixing.&lt;/span&gt;
remain, but do not overmix, a rough mixture is good. Finally fold
in the chopped pieces of chocolate from earlier.&lt;/li&gt;
  &lt;li&gt;Spoon the mixture into the prepared tin and bake for 25 minutes
until the top is crisp and the edge of the cake is beginning to
shrink away from the tin.&lt;/li&gt;
  &lt;li&gt;Leave the cake to cool completely in the tin then cut into squares to serve.&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Sun, 26 Feb 2023 00:00:00 +0000</pubDate>
        <link>https://chriswebsite.com///2023/02/26/Recipe-for-Brownies.html</link>
        <guid isPermaLink="true">https://chriswebsite.com///2023/02/26/Recipe-for-Brownies.html</guid>
      </item>
    
  </channel>
</rss>
