<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
	<channel>
		<title>Mike Street's Blog</title>
		<link>https://www.mikestreety.co.uk</link>
		<description>Blog posts from Mike Street (mikestreety.co.uk)</description>
		<language>en-gb</language>
		<pubDate>Tue, 11 Aug 2026 07:52:01 GMT</pubDate>
		<lastBuildDate>Tue, 11 Aug 2026 07:52:01 GMT</lastBuildDate>
		<atom:link href="https://www.mikestreety.co.uk/rss.xml" rel="alternate" type="application/xml" />
		<image>
			<url>https://www.mikestreety.co.uk/assets/img/favicon-512.png</url>
			<title>Mike Street's Blog</title>
			<link>https://www.mikestreety.co.uk</link>
			<width>144</width>
			<height>144</height>
			<description>Lead Developer and CTO</description>
		</image>
		
		
		<item>
			<title>Setting up Claude Code</title>
			<link>https://www.mikestreety.co.uk/blog/setting-up-claude-code/</link>
			<pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/setting-up-claude-code/</guid>
			<description><![CDATA[
I've recently been utilising and customising Claude Code more - rather than just sticking with what comes out of the box. With
more and more of my colleagues and friends adopting AI (and, specifically, Claude), I thought I would share my go-to config when
setting it all up.


TERMINAL

Claude Code on the terminal is where I spend most of my time - I find it is more powerful than the Code element of the desktop
app. Once Claude is installed, these are the settings I reach for:

 * /model: Sonnet - I've found Sonnet to be a good reliable workhorse without burning all the tokens
 * /effort: High - Effort was explained to me like "motivation" - High is a good, solid balance
 * /advisor: Opus - This is a killer feature - it does a lot of the thinking in Sonnet and then checks with Opus
 * /config - Some settings I change from the default
   * Default permission mode: Plan - Means I don't have to remember to switch, sometimes the plan gives you a solution without
     needing to burn more tokens implementing it
   * Push when actions required: true


MCP / CLI TOOLS

The next thing I lean on is MCPs and CLIs to expand the capabilities of Claude

MCPs

Model Context Protocol (MCP) servers allow LLMs and AI tools to connect to live data to get more context for the task.

 * ClickUp [https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server] - This is our project management
   tool and allows Claude to get the full context of a task
 * GitLab [https://docs.gitlab.com/user/model_context_protocol/mcp_server/#connect-claude-code-to-the-gitlab-mcp-server] - We use
   GitLab for our projects so allowing Claude to access Work Items (issues) and MR feedback helps speed up the feedback loop

CLIs

With certain CLI tools, Claude is able to perform actions more efficiently which saves tokens

 * GitLab CLI [https://docs.gitlab.com/cli/] - Access issue and merge request information over a simplified CLI
 * Playwright [https://playwright.dev/docs/getting-started-cli] - Preferred over the Playwright MCP


SKILLS & PLUGINS

Not so much a config or command in itself, but if you find yourself repeating particular instructions (e.g. how to make a PHPUnit
test or code something in a particular way) consider making a skill.

The best way to do this is to ask Claude itself:

"Make a skill that helps me write consistent..."

Don't forget to specify if it should be a project or user skill.

You can use the skill creator plugin to help you create skills:

 1. Type /plugin
 2. Search for skill-creator, press "Enter" and install it
 3. Type /reload-plugins to register it
 4. Type /skill-creator:skill-creator with a description to get you started

It's worth looking through the /plugin list for plugins which may help with the type of coding you do.


LEARNING

Master.dev features a free course on Claude [https://master.dev/courses/claude-code/]. It doesn't go into deep complex
architecture, but gives a great overview to help you understand Claude better and how to get the best of it


CONCLUSION

And that's it, I'll update the post as I find more customisations and tweaks. For now, this is how I set up my Claude code. If
you've got this far, you might be interested to read my true feelings about AI
[https://www.mikestreety.co.uk/blog/my-thoughts-on-ai].
<p><strong>Read time:</strong> 2 mins</p>
<p><strong>Tags:</strong> AI, General</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Old MacDonald outsourced his farm, AI, AI. Oh.</title>
			<link>https://www.mikestreety.co.uk/blog/old-macdonald-outsourced-his-farm-ai-ai-oh/</link>
			<pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/old-macdonald-outsourced-his-farm-ai-ai-oh/</guid>
			<description><![CDATA[
No, I don't want AI added to every product, service and website. No, I do not want to have to shell out for 20 different
subscriptions because your product has a specific esoteric LLM that I have to pay separately for just because it can integrate
with your software better.

AI is a tool, it is not a solution. It doesn't fix the problems in your application, it just burns the world while it uses its
baked-in polyfills to get around your bad UI. Too many apps push you to use their AI to help you find information and to "get in
touch" - how about you have better navigation, sign-posting and UX so people can find it themselves without relying on some
half-baked chatbot claiming to have natural language processing?

I was speaking with an accessibility consultant a while ago and he was bemoaning those widgets people can add to their website to
"improve" accessibility. He was saying that, if he needed bigger text or different contrast, he would have his own tool to do it.
You wouldn't expect someone who turns up to a shop in their own wheelchair to be forced to swap it for the shop-provided one. I
feel the same about AI; I have an LLM I am paying for, don't force me to use yours - allow me to continue to use my personalised
assistant within your property.

While I rant on about AI, you'd think I was some sort of hater - but I'm an avid user. It has its place, it is a useful tool but
this scattergun multi-LLM approach the tech industry seems to be taking at the moment needs to stop. Allow us to bring our own LLM
if we have one, expose your service or app as an MCP or plugin, so I can use my customised AI agent of choice to do the things you
desperately need me to use an AI for.

I see AI as a force-multiplier, not a replacement. Companies laying off staff because they "have AI" are missing the point (even
Ford realised their mistake [https://www.bbc.co.uk/news/articles/cgrkd41n2v9o]). AI increases the potential of anyone using it
effectively, and raises the ceiling on how much complexity they can handle. It can churn away at the bug, it can chip away at the
low "grunt level" tasks and allow the human to think [I really wanted to write "transcend" here]. I've found I've got a new
capacity for thinking and problem solving and tackling bigger problems because AI is assessing and proposing a fix for that bug at
the same time.

> AI can be good on its own, but it can be great with a human.

Use it as a rubber-duck. Use it to solve that issue, tick off that lingering to-do or optimise that gnarly bash script. Use it to
build internal tools for reporting and scratching that itch. Use it to aid you in solving production-ready code. Review the plans,
hone the plans, craft the plans but then walk away while it does the grunt work.

Take it bowling, but keep the barriers up, use the ramp to push and get a near-guaranteed strike. That's how you get good results
with it - by putting up guardrails and guides.

It's great for helping you solve that niche problem that you wouldn't find on a Reddit thread or to make sure you're not asking a
ridiculous question. It's wonderful for helping my 7-year-old budding botanist identify plants and flowers.

I've tried a few of the popular LLMs and have settled on Claude as my favoured one. It seems a lot more code-focused (which is
what I mainly use it for) although I will pop over to Gemini if I need/want to do any image/video/music creating or editing (this
is normally for humorous effect rather than anything serious).

Now I've got that off my chest I can go away and have fun.

Note: This post was about getting my current thoughts on AI down as a snapshot. My views may, and will, change.
<p><strong>Read time:</strong> 3 mins</p>
<p><strong>Tags:</strong> AI, General</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Comparing Bitwarden Secrets Manager to Infisical</title>
			<link>https://www.mikestreety.co.uk/blog/comparing-bitwarden-secrets-manager-to-infisical/</link>
			<pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/comparing-bitwarden-secrets-manager-to-infisical/</guid>
			<description><![CDATA[
We've been reviewing our secret management process and have been evaluating tools to help with streamlining it.

Looking at our current services and platforms, we have narrowed it down to either Bitwarden or Infisical (more details below).
This post discusses the differences and compares features.

We have a few specific requirements for our secret management tool; the main one being that the production site/application should
not rely on it to run day-to-day.

Although we will discuss pricing at various points, most of the below was carried out on their free tiers


OVERVIEW


BITWARDEN SECRETS MANAGER

Bitwarden Secrets Manager [https://bitwarden.com/en-gb/products/secrets-manager/] is a service provided by the password management
tool we use. If you don't use Bitwarden, I wouldn't expect you to be considering it. However, 1Password
[https://1password.com/developers/secrets-management] and other password managers often offer a secret management service which, I
suspect, acts in the same way.


INFISICAL

Infisical [https://infisical.com/] is a separate, dedicated, secret management platform.


SIGN UP

Signing up for both is fairly straightforward. It's worth noting that they both offer US/EU hosting (it seems to be a very small
option on the sign up page for both of them). Unfortunately, for both services, it doesn't seem like it is possible to migrate
your data between instances - so make sure you pick the right one.

If you already use Bitwarden, adding on the Secrets Manager is a case of visiting your billing profile. One thing that wasn't
clear was that you don't have to pay for every person in your company - you can select who has access to the secrets manager and
only pay for them.


ADDING SECRETS

Infisical has the idea of projects and environments. Once you make a project, you can add a secret to an environment (one or
many). You can then see which environments have which secrets and copy between (should you need to). When calling it, you can pass
in an --env flag to get the different environment secrets.

Bitwarden is more of a flat structure - there is a project which has secrets. If you wish to have different "environments", then
you would use multiple projects. This is managed by passing in the project-id.

Infisical has an interface which allows you to paste an existing .env file and import the secrets from there. With Bitwarden, you
have to add them individually.


LOCAL INSTALLATION

Both Bitwarden and Infisical have a CLI to programmatically interact with secrets.

 * Installing Infisical [https://infisical.com/docs/cli/overview]
 * Installing Bitwarden Secrets Manager CLI [https://bitwarden.com/en-gb/help/secrets-manager-cli/]

From a MacOS perspective, the following was required:


INFISICAL

brew install infisical/get-cli/infisical
infisical login

Infisical login is interactive and loads the browser for access.

For each project, you'll then need to run the following:

infisical init

This gives you an interactive prompt to select the organisation & project for the secrets - this creates a .infisical.json file
which can be committed for other developers to take advantage of. This has several configuration options
[https://infisical.com/docs/cli/project-config] you can set.


BITWARDEN

curl https://bws.bitwarden.com/install | sh
bws config server-base https://vault.bitwarden.eu # if you chose the EU one

You then need to make a machine account and associate it with the projects to use.

Once done, you can set the auth token

export BWS_ACCESS_TOKEN=0.48c78342-1...Iq6Bow==


USAGE


DDEV

The first requirement is for local development - we use DDEV for our TYPO3 sites and both CLIs offer an out-of-the-box method of
injecting the environment variables into the ddev container at runtime rather than leaving .env files lying around.

By adding an extra step, it is then a conscious decision to include the secrets, rather than them being there by default.

When using a secrets manager with ddev, you need to specify each variable in your `.ddev/config.yaml` file:

web_environment:
  - DB_PASSWORD=${DB_PASSWORD}
  - API_KEY=${API_KEY}

INFISICAL

Once you have your .infisical.json file, you can run

infisical run --env=dev -- ddev start

This can be shortened by setting defaultEnvironment and gitBranchToEnvironmentMapping in your JSON config.

BITWARDEN

Bitwarden has no concept of storing the project locally, so it needs to be specified on launch. For example:

bws run --project-id 7b006643-89c1-4202-a5ca-90510f566030 -- ddev start


ENVIRONMENT FILES (.ENV)

The next requirement of ours was to build .env files for production. We want the projects to work whether our secrets management
tool is online or not. For this, we need to export the secrets from the tool during the CI process.

INFISICAL

Infisical has a .env export for this very purpose:

infisical export --env=prod > .env

BITWARDEN

Bitwarden too has a native .env export:

bws secret list 7b006643-89c1-4202-a5ca-90510f566030  -o env > .env


COST

At time of writing, for unlimited projects:

 * Bitwarden is $6 per user per month (with up to 20 machine accounts)
 * Infisical is $20 per identity per month (note: even machine accounts count as identities)

However, you can self-host [https://infisical.com/docs/self-hosting/overview] Infisical for the cost of a small VPS (around $8 a
month) - although you have the added "cost" of maintenance and updates (and security).


A NOTE ON PROJECTS

With Infisical, the .infisical.json file is native - it keeps you and your teammates in sync on the project, and the environments
can be switched with a --env flag.

With Bitwarden, the project ID needs to be stored somewhere, or accessed each time. This could be used as an extra security step
but could lead to project secrets being injected into the wrong project. A minor issue in development but a major one if done on
production.


CONCLUSION

For now, we're still in discussions as to how we want to work and what we want. We are already in the Bitwarden ecosystem, but the
features of Infisical are appealing.

Let me know if you've faced a similar decision and what you ended up with.
<p><strong>Read time:</strong> 4 mins</p>
<p><strong>Tags:</strong> CLI</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Setting up a central bastion/jump server</title>
			<link>https://www.mikestreety.co.uk/blog/setting-up-a-central-bastion-jump-server/</link>
			<pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/setting-up-a-central-bastion-jump-server/</guid>
			<description><![CDATA[
With several developers and a great deal of production servers we need access to, managing SSH keys and access across them all was
getting complex with potential for mistakes and missed authentication.

Instead, we decided to set up a bastion/jump host (with a redundant backup) - this means the client servers need just 2 SSH keys
added and we manage authentication and access for the team in a central location.

Note: This post has been sitting in my drafts for years and I'm posting it out of posterity. We've since moved to a more robust
solution which I'll blog about later

This is managed by a central, private, git repository which is synced to both servers regularly - enabling addition (and
revocation) of clients and staff, with a central source of truth.

Our set up was mainly based off/inspired by Zander's Jump Host [https://zanderwork.com/blog/jump-host/] blog post.


SETUP

This assumes a Debian VPS server. SSH in and update the basic packages along with installing required applications

apt update && apt upgrade -y
apt install curl vim iptables fail2ban -y


USER CREATION

Next, create a baseline user with no password and create an empty authorized_keys file

adduser --disabled-password --gecos "" ingress
su - ingress
cd ~
mkdir .ssh
touch .ssh/authorized_keys
chmod 600 .ssh/authorized_keys
nano .ssh/authorized_keys

Inside authorized_keys file, put the keys of everyone who needs access to the jump server


SSH RESTRICTIONS

Next step is to restrict the SSH access and disable it for root. In doing so, SSH will be disabled - ensure you have access via
your hosting (they normally have a web terminal you can access)

Edit the main SSHD Config file (/etc/ssh/sshd_config) - we replaced the whole contents.

/etc/ssh/sshd_config

# Move SSH to a non-standard port.
Port 2233

# Increase the log verbosity
LogLevel VERBOSE

# Disable root login
PermitRootLogin no

# Enable authentication with keypairs
PubkeyAuthentication yes

# Hardcode directory used for authorized public keys (this is expanded to $HOME/.ssh/authorized_keys)
AuthorizedKeysFile .ssh/authorized_keys

# Disable host authentication
HostbasedAuthentication no

# Explicitly disable .rhosts files
IgnoreRhosts yes

# Disable password-based auth
PasswordAuthentication no

# Disable empty passwords (this doesn't really matter because we disabled password auth but extra verbosity won't hurt)
PermitEmptyPasswords no

# Disable challenge/response auth
ChallengeResponseAuthentication no

# Enable PAM modules
UsePAM yes

# Allow SSH clients to forward SSH agents to use this host as a proxy
AllowAgentForwarding yes

# Disable SSH remote forwarding
GatewayPorts no

# Disable X11 forwarding. Depending on your use case you may need to enable this.
X11Forwarding no

# Disable printing the MOTD. This can be enabled/configured to your liking.
PrintMotd no

# Accept locale variables from SSH client
AcceptEnv LANG LC_*

# Whitelist the lowpriv user. This (in combination with PermitRootLogin no) effectively disables all other users from SSHing in.
AllowUsers ingress


FIREWALL

Next is to lock down the firewall. If your team are accessing all from a predictable set of IPs, it would be an improvement to add
them here to increase the security further

touch /etc/network/if-up.d/00-firewall
chmod +x /etc/network/if-up.d/00-firewall
nano /etc/network/if-up.d/00-firewall

/etc/network/if-up.d/00-firewall

#!/bin/sh

#
# Firewall rules
#
iptables -P INPUT ACCEPT
iptables -P OUTPUT ACCEPT
iptables -P FORWARD ACCEPT

#
# Delete all existing rules
#
iptables -F
iptables -t nat -F
iptables -t mangle -F
iptables -X

#
# Enable free use of loopback interfaces
#
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT

#
# Allow established connections
#
iptables -A INPUT -m state --state ESTABLISHED -j ACCEPT

iptables -A INPUT -p tcp --dport 2233 -j ACCEPT

iptables -A INPUT -j DROP


PREVENT BRUTE FORCING

The next step prevents people repeatedly trying different passwords & other access

systemctl start fail2ban
systemctl enable fail2ban
nano /etc/fail2ban/jail.d/sshd.conf

/etc/fail2ban/jail.d/sshd.conf

[sshd]
enabled = true
port = 2233
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
findtime = 60
bantime = 1800


UNATTENDED UPGRADES

Installed unattended upgrades to ensure the server stays patched and up-to-date

Setup

To setup and configure

apt update && apt upgrade && apt autoclean
apt install unattended-upgrades -y
systemctl enable unattended-upgrades
systemctl start unattended-upgrades

Edit the config file /etc/apt/apt.conf.d/50unattended-upgrades

nano /etc/apt/apt.conf.d/50unattended-upgrades

 1. Uncomment out "origin=Debian,codename=${distro_codename}-updates"; (so it is enabled)
 2. Set Unattended-Upgrade::Mail "YOUREMAIL"; - search for Mail and set the value
 3. Set Unattended-Upgrade::Remove-Unused-Dependencies "true";

Edit /etc/apt/apt.conf.d/20auto-upgrades and replace the contents with the following

nano /etc/apt/apt.conf.d/20auto-upgrades

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";

Test it with

unattended-upgrades --dry-run --debug


FINAL CHECKS

 * Restart SSH: /etc/init.d/ssh restart - Can you SSH in as ingress user?
 * Reboot the server - can you SSH in?
<p><strong>Read time:</strong> 4 mins</p>
<p><strong>Tags:</strong> CLI</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Notify Slack when an SSH connection is made with sshpiper</title>
			<link>https://www.mikestreety.co.uk/blog/notify-slack-when-an-ssh-connection-is-made-with-sshpiper/</link>
			<pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/notify-slack-when-an-ssh-connection-is-made-with-sshpiper/</guid>
			<description><![CDATA[
Keeping track of which developer is connecting to which server can be a job in itself. There are plenty of ways to handle it, from
sending logs to dedicated software to writing them to a file that someone has to check manually.

Our sshpiper [https://github.com/tg123/sshpiper] setup takes a different approach and sends a Slack notification to a dedicated
channel whenever a connection happens. This means we can keep an eye on things and quickly confirm that connections are genuine
and expected.

To make this work, we built a custom notification plugin that sits between the existing fail2ban
[https://github.com/tg123/sshpiper/blob/master/plugin/failtoban] and workingdir
[https://github.com/tg123/sshpiper/blob/master/plugin/workingdir] plugins.

A working sshpiper install is required for this


OVERVIEW

The files used for this plugin are

 * /plugins/authnotify/main.go - The go code (which gets compiled)
 * /etc/sshpiperd/slack-webhook-url - Text file with your incoming webhook


THE PLUGIN CODE

This is a slightly trimmed down version of the one we have so you can get the basics going. There's an explanation at the end as
to the additional checks we have. Feel free to reach out [https://www.mikestreety.co.uk/contact/] if you'd like an example.

Create a file/folder for you plugin (ours lives in /plugins/authnotify/main.go)

main.go

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"os"

	"github.com/tg123/sshpiper/libplugin"
	"github.com/urfave/cli/v2"
	"golang.org/x/crypto/ssh"
)

func main() {
	libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{
		Name: "authnotify",
		Flags: []cli.Flag{
			&cli.StringFlag{Name: "authorized-keys", Value: "/etc/sshpiperd/shared/authorized_keys"},
			&cli.StringFlag{Name: "slack-webhook-url-file"},
		},
		CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) {
			authorizedKeysPath := c.String("authorized-keys")

			webhookURL := ""
			if f := c.String("slack-webhook-url-file"); f != "" {
				if b, err := os.ReadFile(f); err == nil {
					webhookURL = string(bytes.TrimSpace(b))
				}
			}

			return &libplugin.SshPiperPluginConfig{

				// Runs once per key the client's SSH agent offers.
				PublicKeyCallback: func(conn libplugin.ConnMetadata, key []byte) (*libplugin.Upstream, error) {
					identity := lookupIdentity(authorizedKeysPath, key)
					slog.Info("auth attempt", "identity", identity, "remote", conn.RemoteAddr())

					if identity == "" {
						// Not our key to judge — offer the agent's next key
						// instead of rejecting (that would trip failtoban)
						// or advancing (we'd miss whichever key actually works).
						return &libplugin.Upstream{
							Auth: libplugin.CreateRetryCurrentPluginAuth(map[string]string{}),
						}, nil
					}

					// Matched: hand off to workingdir, which makes the real
					// accept/reject decision. Carry identity forward for later.
					return &libplugin.Upstream{
						Auth: libplugin.CreateNextPluginAuth(map[string]string{"identity": identity}),
					}, nil
				},

				// Runs only once a connection is actually accepted and piped.
				PipeStartCallback: func(conn libplugin.ConnMetadata) {
					identity := conn.GetMeta("identity")
					ip, _, _ := net.SplitHostPort(conn.RemoteAddr())
					postToSlack(webhookURL, identity, conn.User(), ip)
				},
			}, nil
		},
	})
}

// lookupIdentity re-reads and re-parses authorized_keys on every call. The
// real plugin caches this on file mtime (see authnotify's authorizedKeysCache)
// so the hot auth path isn't re-parsing the file per offered key — trimmed
// here for clarity.
func lookupIdentity(path string, offeredKeyBytes []byte) string {
	offeredKey, err := ssh.ParsePublicKey(offeredKeyBytes)
	if err != nil {
		return ""
	}

	data, err := os.ReadFile(path)
	if err != nil {
		return ""
	}

	rest := data
	for len(rest) > 0 {
		key, comment, _, remainder, err := ssh.ParseAuthorizedKey(rest)
		if err != nil {
			break
		}
		if key != nil && bytes.Equal(key.Marshal(), offeredKey.Marshal()) {
			return comment
		}
		rest = remainder
	}

	return ""
}

func postToSlack(webhookURL, identity, client, ip string) {
	if webhookURL == "" || identity == "" {
		return
	}
	payload, err := json.Marshal(map[string]string{
		"text": fmt.Sprintf("SSH: %s connected to *%s* from %s", identity, client, ip),
	})
	if err != nil {
		return
	}
	http.Post(webhookURL, "application/json", bytes.NewReader(payload))
}


BUILD THE PLUGIN

Once saved, we need to compile the plugin and move to a more suitable location (/opt/sshpiper/bin/ is where we have compiled all
of our sshpiper plugins)

cd plugins/authnotify && go build -o /opt/sshpiper/bin/authnotify .


SLACK WEBHOOK

Next, create a file (/etc/sshpiperd/slack-webhook-url) which contains your Slack incoming webhook (you'll need to make an app
[https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/] to get this)

nano /etc/sshpiperd/slack-webhook-url

We then need to lock down the permissions as best we can on the file

chown sshpiper:sshpiper /etc/sshpiperd/slack-webhook-url
chmod 600 /etc/sshpiperd/slack-webhook-url


WIRE UP THE PLUGIN

We have our sshpiper instance running as a service, but however you initialise sshpiper, add the custom plugin

Note: Update the path to your authorized_keys file, as well as the slack-webhook-url if different.

/opt/sshpiper/bin/failtoban \
    --max-failures=20 --ban-duration=15m --ignore-ip=127.0.0.1 \
    -- \
    /opt/sshpiper/bin/authnotify --authorized-keys /etc/sshpiperd/shared/authorized_keys \
        --slack-webhook-url-file /etc/sshpiperd/slack-webhook-url  \
    -- \
    /opt/sshpiper/bin/workingdir --root /etc/sshpiperd/hosts --strict-hostkey


ADDITIONS

There are a few additions to our plugin we have (removed from above for simplicity) which might be worth exploring:

 * authorized_keys caching - cache the authorized_keys file based on modified time to save loading it each connection
 * Grace period - ours inspects user, ip and target and, if it matches one from the last 30 seconds it skips sending the Slack
   message (handy for repeated or flaky connections)

If you would like more information or an example of the expanding plugin, let me know [https://www.mikestreety.co.uk/contact/].
<p><strong>Read time:</strong> 6 mins</p>
<p><strong>Tags:</strong> CLI, Slack</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Set up a private packagist using a server and open source</title>
			<link>https://www.mikestreety.co.uk/blog/set-up-a-private-packagist-using-a-server-and-open-source/</link>
			<pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/set-up-a-private-packagist-using-a-server-and-open-source/</guid>
			<description><![CDATA[
Publishing private composer packages is a fiddly business - especially if you want a usable UI along with it.

After much research I came across Packeton [https://github.com/vtsykun/packeton] - an open source fork of Packagist which you can
run on a web server of your choosing.

This walkthrough sets Packeton up with Docker which requires the least amount of server setup.

The following how-to runs through setting it up and some hurdles I came across. It expects CLI experience and you need to be
comfortable with SSH.


WHERE TO RUN

You need a server or VPS for this - I opted for a cloud server from Hetzner [https://www.hetzner.com/] with Ubuntu 24 running.


SERVER SET UP

Update the server applications and install caddy (which allows web traffic to docker images) and docker itself.

apt update && apt upgrade -y
apt install -y caddy
curl -fsSL https://get.docker.com | sh


DNS

Point your domain (e.g. packages.yourdomain.com) at the server's public IP


FIREWALL

Set up a firewall with the following inbound rules - I used the firewall built into the Hetzner control panel

Port Protocol Source 22 TCP Any IPv4, Any IPv6 80 TCP Any IPv4, Any IPv6 443 TCP Any IPv4, Any IPv6


GENERATE AN APP SECRET

This can be run on the server or your local machine - you just need a 32 character string

openssl rand -hex 32

Copy the output for use in the next step. Keep it static — it's used to encrypt SSH keys in the database.


CREATE YOUR DOCKER COMPOSE FILE

I chose to keep all my Packeton-related files in /opt/packeton. Start off by making the folder & file

mkdir -p /opt/packeton
nano /opt/packeton/docker-compose.yml

This utilises a few different settings & configuration. Some points worth noting

 * This include configuration for using Mailgun (we use it on the free tier) for sending the password reset emails
 * This includes watchtower which will keep packeton updated

services:
  packeton:
    image: packeton/packeton:latest
    container_name: packeton
    restart: unless-stopped
    environment:
      APP_SECRET: <output from step 5>
      ADMIN_USER: admin
      ADMIN_PASSWORD: changeme
      ADMIN_EMAIL: you@yourdomain.com
      PACKAGIST_DIST_HOST: https://packages.yourdomain.com
      TRUSTED_PROXIES: 127.0.0.1
      MAILER_DSN: smtp://you%40yourdomain.com:PASSWORD@smtp.eu.mailgun.org:587
      MAILER_FROM: Your Name <you@yourdomain.com>
    ports:
      - '127.0.0.1:8080:80'
    volumes:
      - ./data:/data
  watchtower:
    image: containrrr/watchtower
    restart: unless-stopped
    environment:
      DOCKER_API_VERSION: "1.40"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: --interval 86400

Note: ADMIN_USER and ADMIN_PASSWORD only apply on first run. Change the password afterwards via the console (see Post-setup).
Note: If using Mailgun, make sure you use the EU SMTP host (smtp.eu.mailgun.org) if your domain is on the EU region. Use the full
email address as the SMTP username, URL-encoding the @ as %40.


CONFIGURE CADDY

Caddy allows a domain name to be forwarded to a running docker container.

Replace the entire contents of /etc/caddy/Caddyfile with:

packages.yourdomain.com {
    reverse_proxy localhost:8080
}


Then reload caddy:

systemctl reload caddy


START PACKETON

cd /opt/packeton
docker compose up -d


VERIFY IT ALL WORKS

Visit https://packages.yourdomain.com and log in with the admin credentials you set.


POST-SETUP


CHANGE THE ADMIN PASSWORD

The ADMIN_PASSWORD env var only applies on first run. Change it via the console:

docker exec -it packeton bin/console packagist:user:manager admin --password=newpassword


CREATE ADDITIONAL ADMIN USERS

docker exec -it packeton bin/console packagist:user:manager newusername --password=newpassword --admin --no-interaction


CONFIGURE GITLAB OAUTH

First, create a GitLab OAuth application at https://gitlab.com/-/profile/applications:

 * Redirect URIs:
   
   https://packages.yourdomain.com/oauth2/gitlab/install
   https://packages.yourdomain.com/oauth2/gitlab/check
   

 * Scopes: api, read_user, read_repository

Then create /opt/packeton/data/config.yaml:

packeton:
    integrations:
        gitlab:
            base_url: 'https://gitlab.com/'
            clone_preference: 'clone_https'
            gitlab:
                client_id: 'xxx'
                client_secret: 'xxx'

Restart Packeton to apply:

cd /opt/packeton
docker compose restart packeton

Then go to the Packeton integrations page in the UI and click Install Integration, then Connect to complete the OAuth flow.


BONUS NOTES


DATA

All Packeton data lives in /opt/packeton/data on the host, mapped to /data inside the container. Back this directory up — it
contains the database, config, and any stored artifacts.


ONGOING MAINTENANCE

Watchtower checks for a new packeton/packeton:latest image daily and recreates the container automatically. No action needed.

Monitor the Packeton releases [https://github.com/vtsykun/packeton/releases] for any updates that require manual migration steps
before they land.
<p><strong>Read time:</strong> 4 mins</p>
<p><strong>Tags:</strong> CLI</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Checking your websites with the BLAT test</title>
			<link>https://www.mikestreety.co.uk/blog/checking-your-websites-with-the-blat-test/</link>
			<pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/checking-your-websites-with-the-blat-test/</guid>
			<description><![CDATA[
You've been staring at the same project for weeks. Your design eye is shot and the finish line is in sight. You can't see the wood
for the trees.

This is exactly when you need fresh eyes. At Liquid Light, that's what the BLAT test is for.

BLAT is a timeboxed, no-holds-barred review where everyone gets a go at one of our nearly finished websites. Each person gets an
hour to click around, prod things, and use the site as a real person would. The goal is to find holes.

It might be a personal preference. It might be a niggle. It might be the tiniest of nitpicks. Doesn't matter. It goes on the list.

The project manager then reviews the list and decides what to action, postpone, or drop. It's not personal. It's priorities.

Things that tend to come up:

 * Spacing between two particular elements
 * Accessibility of a link in a specific context
 * Unexpected (or missing) behaviour from an interaction
 * Image sizes affecting performance
 * Print styles nobody tested
 * Odd flows between pages
 * Future website improvements or additions

Anything goes, as long as the note includes:

 * A link
 * A description
 * A screenshot where possible

Side note: No, BLAT doesn't actually stand for anything. We all know what it means (although out of all the recommendations from
Claude my favourite was Brutally Look At Things )
<p><strong>Read time:</strong> 1 mins</p>
<p><strong>Tags:</strong> General, Testing</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Completely remove DDEV from your computer</title>
			<link>https://www.mikestreety.co.uk/blog/completely-remove-ddev-from-your-computer/</link>
			<pubDate>Fri, 06 Feb 2026 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/completely-remove-ddev-from-your-computer/</guid>
			<description><![CDATA[
I recently ran into an issue with DDEV 1.25 and was upgrading and downgrading between the two versions to test, check and verify.

Eventually, my DDEV got confused and started producing 404s. With mixed version images & config, I wanted to remove everything and
start again.

With the help of Claude, I created a bash script which will run through and delete every DDEV related configuration.

Completely remove DDEV [https://gist.github.com/mikestreety/07d531b346ab8ce9c62ec655dd4274a4]


STEPS

 1. Copy the contents or download the zip
 2. Make the file executeable - cd path/to/file and chmod +x ./remove-ddev.sh
 3. Run the script ./remove-ddev.sh - there is --help and --dry-run flags available
<p><strong>Read time:</strong> 1 mins</p>
<p><strong>Tags:</strong> CLI</p>
			]]></description>
		</item>
		
		
		<item>
			<title>2025 In Review</title>
			<link>https://www.mikestreety.co.uk/blog/2025-in-review/</link>
			<pubDate>Wed, 31 Dec 2025 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/2025-in-review/</guid>
			<description><![CDATA[
Like 2024, 2025 passed without major incident or upheaval. All in all, it was an enjoyable year - seeing some firsts for us all.


LIFE

Plenty of goings-on with the Street family this year. Ruby, our youngest, started school which meant another shift in routines and
schedules. Alfie moved up to Beavers and Ruby started Squirrels, which now means I'm the only one of our family to not be
currently invested in a scouting section.

Alfie has become a classic "kid" and discovered Minecraft. He'd been talking about it at school and we finally gave in and bought
a copy for the XBOX. I've also enjoyed playing it, trying to actually build things and thinking about layout (although it's still
enjoyable to build a tower of TNT and blog it up).

There were some small home improvements - I redecorated the garden office and painted the "TV corner" of the lounge with matching
bookcases. The biggest change was the demolition of the back garden patio and building of a deck - carried out by my dad and me.

To finish off the year, our car decided to give us a Christmas present of breaking. We couldn't get it booked in until the 5th
Jan, however my mother-in-law leant us her car for the festive period which meant Christmas was saved.


TRIP AND HOLIDAYS

For our main holiday this year we took the kids to Disneyland Paris. It was a first for me - going on the channel tunnel and
driving on foreign soil in my own car. Disneyland was hectic and expensive and fun and chaos. We went with my wife's family which
did mean we had the opportunity to leave the kids with each other and head off to the big rides.

Little trips included taking Ruby to London for the first time, a boat trip out to the Rampion Wind Farm
[https://en.wikipedia.org/wiki/Rampion_Wind_Farm] and a visit to Brooklands Museum in Weybridge.

We also spent a week in a static caravan in the New Forest - it was a classic "caravan park" style holiday, with on-site swimming,
golf and evening entertainment. We sprinkled in some day trips to Paultons Park and a miniature steam railway. We also found a pub
round the corner with an incredible outdoor play area for the kids (while Chilly and I kicked back with a book and a beer).

My attendance to gigs sky-rocketed this year as I got the taste for it last year. This year saw a few shows with the kids along
with seeing OneRepublic (my first trip to the 02 since it was the millennium dome), Nizlopi (a birthday present), Self Esteem and
it seems I can't go through a year without seeing Bastille. The last gig of the year was with my mum and family to see
Stereophonics at the 02 again.


STATS ANALYSIS

Visit the stats page [https://www.mikestreety.co.uk/stats/].

Cycling was a big part this year - recording the highest ever number of miles done in a year since I started recording. A big part
of this was the turbo trainer I purchased at the end of last year, with just 85 miles separating virtual and real-world bike rides
(and I rode more virtual miles then I did on an eBike)!

I also hit a few big rides this year, doing the London to Brighton bike ride (for the third time), a 65 mile bike ride in August
and 70 miles around the Isel of Wight in October. I was pretty happy that I was able to pull these out the bag without much
concious training. It seems cycling to and from work combined with the turbo proved to be a pretty effective training plan. I'd
like to hit 4000 miles again in 2026.

My Geocaching suffered this year - only finding 2 at the beginning of the year. I need to get some caching days booked in in 2026
to get those numbers back up. I feel like 75 Geocaches is a good target.

Everything else, such as blog posts, steps and music streams stayed steady.
<p><strong>Read time:</strong> 3 mins</p>
<p><strong>Tags:</strong> General, Ramblings, Annual Review</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Makefile includes and other Makefile tips and tricks</title>
			<link>https://www.mikestreety.co.uk/blog/makefile-includes-and-other-makefile-tips-and-tricks/</link>
			<pubDate>Sun, 28 Dec 2025 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/makefile-includes-and-other-makefile-tips-and-tricks/</guid>
			<description><![CDATA[
We use Makefiles in our repositories to wrangle commands for setting up, pulling and linting our code. Nothing revolutionary, but
they've become pretty essential to our workflow.


MAKEFILE INCLUDES

The thing that initially had me scratching my head was wanting to share commands between sites. A lot of our sites follow the same
patterns and need identical Makefile commands - seemed daft to copy-paste the same stuff everywhere when we could have one central
file doing the heavy lifting.

I spent ages trawling through Stack Overflow and various forums, but here's the thing - because our Makefiles essentially just
contain bash commands (rather than actually "making" anything in the traditional sense), there was loads of conflicting advice
that didn't quite fit our use case. Eventually, I gave up being stubborn and asked AI, which promptly gave me exactly what I
needed:

-include ./path/to/file.mk

Word of warning: That hyphen - before include is doing important work - it prevents Make from throwing a tantrum if the file
happens to be missing (which can happen if it's installed via a dependency that hasn't been pulled yet).

.mk is the recognised file extension for Makefiles that aren't called Makefile - bit of trivia for you there.

When you're including a file with commands, you can overwrite them in your local Makefile if you need project-specific tweaks.
Make will give you a gentle warning about this, but it's just letting you know something's been overridden - nothing to worry
about.


VARIABLES

Your central Makefile might need some paths or other bits that vary between projects. You can handle this much like bash - define
variables without a prefix, use them with one:

SITE_PATH_PRODUCTION := ~/www/current

-include ./path/to/file.mk

Then reference that variable in your shared Makefile:

## Pull the full database from production
db-full-pull-production:
	ssh $(SSH_HOST) \
		"$(SITE_PATH_PRODUCTION)/vendor/bin/typo3 database:export ...

As a bit of a safety net, you can set defaults at the top of your shared makefile too:

SITE_PATH_PRODUCTION ?= ~/www/current

That way things won't explode if someone forgets to set a variable.


.PHONY COMMANDS

All our commands are basically bash scripts masquerading as Make targets. This works fine until you accidentally create a folder
that matches one of your command names.

For example, if you've got make config but also have a config/ folder hanging about, running make config will target the folder
instead of your command. Bit annoying when you're expecting it to do something entirely different.

You can tell Make which commands are .PHONY (i.e., they don't correspond to actual files), but since all of ours are just bash
commands in disguise, we take the sledgehammer approach and mark everything as phony:

.PHONY: *


HELP BLOCK

Here's something that'll save you from accidentally running the wrong command: by default, running make with no target executes
whatever's first in the file. This could be anything - a rebuild command, a deployment script, something that downloads half the
internet. Not ideal.

We stick a help generator at the top of our Makefiles that serves double duty - it shows available commands and acts as a safety
net:

help:
	@echo "\033[0;33mAvailable targets\033[0m"
	@echo "\033[0;33m-----------------\033[0m"
	@awk '/^[[:alnum:]_-]+:/ { \
		helpMessage = match(lastLine, /^## (.*)/); \
		if (helpMessage) { \
			helpCommand = substr($$1, 0, index($$1, ":")-1); \
			helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \
			printf "%-25s %s\n", helpCommand, helpMessage; \
		} \
	} \
	{ lastLine = $$0 }' $(MAKEFILE_LIST)

Any command with a double hash comment (##) above it gets picked up and displayed in the help. Simple but effective - and it means
accidentally running make just shows you what's available rather than doing something potentially destructive.

Certainly not the most groundbreaking setup, but it's made managing our various projects much less of a faff. If you've got a
different approach or improvements to suggest, I'd love to hear about them.
<p><strong>Read time:</strong> 3 mins</p>
<p><strong>Tags:</strong> CLI</p>
			]]></description>
		</item>
		
		
		<item>
			<title>2025 Quiz of the Year</title>
			<link>https://www.mikestreety.co.uk/blog/2025-quiz-of-the-year/</link>
			<pubDate>Sat, 27 Dec 2025 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/2025-quiz-of-the-year/</guid>
			<description><![CDATA[
This years quiz of the year [category/quiz/] needs the following:

 * The slides (linked below)
 * The info and notes below
 * Pens and paper for your teams

The quiz can be played in teams or individually - I'll leave it to you to work it out. There doesn't need to be a "quiz master"
per say, just someone who can click "next slide please".


SLIDES

Get the quiz slides [https://docs.google.com/presentation/d/1iHsGoROP03uZz5BUKbMiLn9i4Tk7WISxWbR-ZgkYClY/edit?usp=sharing]

The slides are on Google, however if you need them in a different format, let me know [https://www.mikestreety.co.uk/contact/].


QUIZ INFORMATION

This quiz is 5 rounds with 7 questions in most rounds (except the picture round, which has 11).

Most answers are 2 points per correct answer (allowing single points to be rewarded where deserved).

When running the quiz I ask that phones are put away - more for politeness than fear of cheating. I also make it clear that the
answers in the quiz are always right - even if they are not. This way it is fair and should hopefully avoid arguments.


ROUND EXPLANATIONS

1.BATTLENIPS (AND OTHER PARTS)

Identify the grid reference where the specified body part of object is.

2. MUSIC

Fill in the missing lines of the song. Single points can be awarded if the teams are nearly right.

3. FILM

Identify the films featuring my face

4. PICTURE

I got my 7 & 4 year-olds to draw things from the garden and park. What are they?

5. 2025

7 questions about what happened in 2025.


THE END

Let me know if you use this quiz and how you get on - was it to easy? to hard? to complicated?
<p><strong>Read time:</strong> 1 mins</p>
<p><strong>Tags:</strong> General, Quiz</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Keeping RustFS clear of old assets</title>
			<link>https://www.mikestreety.co.uk/blog/keeping-rustfs-clear-of-old-assets/</link>
			<pubDate>Thu, 06 Nov 2025 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/keeping-rustfs-clear-of-old-assets/</guid>
			<description><![CDATA[
After having RustFS [https://www.mikestreety.co.uk/blog/setting-up-rustfs-as-an-amazon-s3-replacement/] running as our Gitlab CI
cache [https://www.mikestreety.co.uk/blog/use-minio-to-cache-gitlab-containers-and-runners/] for a few weeks the server (as
expected) filled up.

Since we're only using RustFS to cache build assets, we can safely bin the old ones without worry. We settled on a 14-day cut-off
- bit arbitrary really, but it works. The worst that can happen is the application won't deploy without them, which means you'll
have to re-run the entire pipeline if you're trying to deploy something that hasn't been built in a fortnight. Not ideal, but
hardly the end of the world.

RustFS is comapitble with mc - the MinIo command so I started looking there but then ended up with a default linux command

find /data/rustfs0/XXX -mindepth 1 -type f -mtime +14 | xargs rm

Note: Make sure you specify the path (XXX in the example above) to your bucket as RustFS stores configuration in
/data/rustfs0/.rustfs.sys - I ended up deleting our user access by removing files older than 14 days in this folder

Once you have the command and are happy with it, add it to a crontab to run once a night.

To edit the crontab, run crontab -e and place the following at the bottom (this will run a 10pm every evening)

0 22 * * * find /data/rustfs0/gitlab-ci -mindepth 1 -type f -mtime +14 | xargs rm
<p><strong>Read time:</strong> 1 mins</p>
<p><strong>Tags:</strong> CLI</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Email authentication records to improve deliverability</title>
			<link>https://www.mikestreety.co.uk/blog/email-authentication-records-to-improve-deliverability/</link>
			<pubDate>Mon, 13 Oct 2025 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/email-authentication-records-to-improve-deliverability/</guid>
			<description><![CDATA[
Sending emails in this mad world of spam is a tricky business. Spoofing and phishing are all too common, and email providers try
to be smart to it, although sometimes at the detriment to honest and "real" emails.

If your website is sending emails at all (even to you for contact form responses), it is worth considering spending time to verify
that you own the domain and you are allowed to send emails from it. Word of warning: this isn't going to be the most thrilling
post, but it's one of those things that'll save you a proper headache down the line when your carefully crafted emails end up in
spam folders.

SPF, DKIM and DMARC records all help with this and below each one is explained as to what it does and how you set it up. I'll be
honest - I found these records a bit bewildering at first, but once you've set them up a few times they become second nature.
Think of them as your email's passport - proving you are who you say you are.


TESTING TOOLS

Before we dive in, bookmark this:

 * mail-tester [https://www.mail-tester.com/] - Send an email to get real-world data (proper useful, this one - gives you a score
   out of 10 and tells you exactly what's wrong)


SPF RECORD

 * SPF Records explained [https://mailtrap.io/blog/spf-records-explained/]
 * SPF Tester [https://mxtoolbox.com/spf.aspx]

SPF (Sender Policy Framework) is basically your domain saying "these are the mail servers allowed to send email on my behalf".
Without it, anyone could pretend to send emails from your domain - which is as dodgy as it sounds.

This is how the most common SPF record looks:

v=spf1 a mx -all


Breaking this down: v=spf1 is the version, a and mx mean your domain's A record and MX records are allowed to send mail, and -all
means "reject anything else". That last bit is important - it's like saying "if it's not on the list, it's not coming in".

As an example, if your client uses Google Workspace, you'll need to add include:_spf.google.com. Same goes for services like
Mailchimp:

v=spf1 a mx include:_spf.google.com include:mailchimpapp.net -all


Most services which send emails on your behalf will have some documentation detailing what SPF Record you need.


DMARC RECORD

 * DMARC wizard [https://dmarcian.com/dmarc-record-wizard/] (weirdly satisfying to use, this one)

DMARC (Domain-based Message Authentication, Reporting and Conformance) tells receiving mail servers what to do if your SPF or DKIM
checks fail. It also sends you reports so you can see if someone's trying to spoof your domain - certainly interesting to see
what's being attempted in the wild.

A good standard is something like the following:

 * Target: _dmarc.@
 * Type: TXT
 * Record:

v=DMARC1; p=quarantine; rua=mailto:email@example.com; aspf=r;


Or if you want to be a bit stricter:

v=DMARC1; p=quarantine; pct=100; aspf=s;


The p=quarantine means "if this looks dodgy, put it in spam rather than rejecting it outright". You can use p=reject if you're
feeling confident, but I'd recommend starting with quarantine until you're sure everything's configured properly.


DKIM RECORD

This can only be configured if the service you are using emits a DKIM signature or similar. CMS's, like TYPO3, does not include a
DKIM header so make sure you know before you start.

DKIM (DomainKeys Identified Mail) adds a digital signature to your emails - like a wax seal on a letter proving it hasn't been
tampered with. Your email service provider will generate the keys for you.

Check with the system for instructions - each provider does it slightly differently and they'll give you the specific DNS records
to add.


BIMI

Right, this one's a bit fancy and optional, but if you want your logo to appear next to your emails in supported clients (Gmail,
Yahoo, etc.), BIMI is what you need.

 * BIMI Generator & Inspector [https://bimigroup.org/bimi-generator/]
 * Use a 512px square SVG for the image (the Favicon SVG is perfect for this)
 * We don't generally have a VMC (Verified Mark Certificate) available - these cost proper money and are only really worth it for
   big brands

Example BIMI:

 * Target: default._bimi.@
 * Type: TXT
 * Record: v=BIMI1; l=https://link/to/svg;


FINAL THOUGHTS

I should really test email deliverability more systematically on projects, but these DNS records are a good foundation. Set them
up early and you'll avoid that awkward conversation later where the client asks why their contact form emails keep ending up in
spam.

If anyone's got experience with VMC certificates for BIMI or has tips on DKIM implementation in TYPO3, I'd love to hear your
thoughts. There's still a lot of nuance to email deliverability that catches me out occasionally.
<p><strong>Read time:</strong> 3 mins</p>
<p><strong>Tags:</strong> DNS, Email</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Setting up RustFS as an Amazon S3 replacement</title>
			<link>https://www.mikestreety.co.uk/blog/setting-up-rustfs-as-an-amazon-s3-replacement/</link>
			<pubDate>Sun, 12 Oct 2025 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/setting-up-rustfs-as-an-amazon-s3-replacement/</guid>
			<description><![CDATA[
I was at TYPO3 Camp London [https://t3cl25.typo3.com/] recently when Martin Helmich casually dropped that RustFS
[https://rustfs.com/en/] was a solid MinIO replacement. That got my attention.

I've written about MinIO before - speeding up Gitlab CI
[https://www.mikestreety.co.uk/blog/how-i-improved-the-speed-of-docker-builds-in-gitlab-ci/] and caching Gitlab assets
[https://www.mikestreety.co.uk/blog/use-minio-to-cache-gitlab-containers-and-runners/] - and it's been great as a self-hosted S3
alternative. But I'm always up for trying new toys, especially when they promise improvements.

Turns out RustFS has been benchmarked against MinIO and is faster across the board
[https://github.com/orgs/rustfs/discussions/598#discussion-8952907]. That was enough to convince me to give it a go.


SERVER SETUP

We're running RustFS on a dedicated Ubuntu server with Hetzner [https://www.hetzner.com/] (our go-to VPS provider). I went with an
Intel CX32:

 * 4 vCPU
 * 8 GB RAM
 * 80 GB Disk

Note: You'll need an external IPv4 address - rustfs.com only supports IPv4 for setup.


INSTALLATION

Once your VPS is up, installation is pleasantly straightforward. First, make sure you've got unzip:

apt update && apt upgrade
apt install zip unzip

Then grab the install script [https://rustfs.com/download/server/]:

curl -O https://rustfs.com/install_rustfs.sh && bash install_rustfs.sh

Follow the CLI prompts and you're sorted.


SETUP

After installation, hit up the web interface at http://[server-ip]:9000. Default credentials are rustfsadmin for both username and
password.

Change that immediately by editing:

/etc/default/rustfs


Once logged in, you can create buckets and extra users or access keys - which is what I've been using for Gitlab CI.
<p><strong>Read time:</strong> 1 mins</p>
<p><strong>Tags:</strong> CLI</p>
			]]></description>
		</item>
		
		
		<item>
			<title>Shower thoughts that live in my head rent free</title>
			<link>https://www.mikestreety.co.uk/blog/shower-thoughts-that-live-in-my-head-rent-free/</link>
			<pubDate>Sat, 11 Oct 2025 00:00:00 GMT</pubDate>
			<guid>https://www.mikestreety.co.uk/blog/shower-thoughts-that-live-in-my-head-rent-free/</guid>
			<description><![CDATA[
The idea of shower thoughts are things for you to ponder or wonder, these are ones that I have read on the internet (I take no
credit) and often think about for no reason at all.

> Sleep is one of the few things we pretend to do to actually do it

Close your eyes, lie down and pretend until you actually drift off

> Cleaning your teeth is the only time we clean our skeleton

You don't see a skull with a beard, do you?

> Why do we have round lenses on cameras, but rectangle sensors and photos?

Surely the lens is capturing more of the photo then we ever see?

> Have you ever walked in a "space" of the earth that no human has before?

Doesn't matter what is actually under your feet, but has a human ever been to that lat/long before?
<p><strong>Read time:</strong> 1 mins</p>
<p><strong>Tags:</strong> General</p>
			]]></description>
		</item>
		

	</channel>
</rss>
